@analyticscli/growth-engineer 0.1.1-preview.6 → 0.1.1-preview.8

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.
@@ -906,7 +906,9 @@ function sourceCommandNeedsActiveConfig(sourceName, command) {
906
906
  const value = String(command || '').toLowerCase();
907
907
  return (normalized === 'sentry' ||
908
908
  normalized === 'glitchtip' ||
909
+ normalized === 'paddle' ||
909
910
  normalized === 'coolify' ||
911
+ value.includes('export-paddle-summary') ||
910
912
  value.includes('export-sentry-summary') ||
911
913
  value.includes('export-coolify-summary') ||
912
914
  value.includes('exporters coolify-summary'));
@@ -2451,10 +2453,13 @@ async function saveSecretsImmediately(secrets) {
2451
2453
  process.stdout.write(`Saved local secrets to ${secretsFile} with chmod 600.\n`);
2452
2454
  return true;
2453
2455
  }
2454
- async function runImmediateConnectorHealthCheck({ rl, configPath, connector, secrets, sentryAccounts = [], }) {
2456
+ async function runImmediateConnectorHealthCheck({ rl, configPath, connector, secrets, sentryAccounts = [], paddleAccounts = [], }) {
2455
2457
  if (connector === 'sentry' && sentryAccounts.length > 0) {
2456
2458
  await upsertSentryAccountsConfig(configPath, sentryAccounts);
2457
2459
  }
2460
+ if (connector === 'paddle' && paddleAccounts.length > 0) {
2461
+ await upsertPaddleAccountsConfig(configPath, paddleAccounts);
2462
+ }
2458
2463
  await saveSecretsImmediately(secrets);
2459
2464
  const env = {
2460
2465
  ...process.env,
@@ -3133,6 +3138,71 @@ async function verifySentryAccountsConfig(configPath, expectedAccounts) {
3133
3138
  }
3134
3139
  return { ok: true, detail: `${realAccounts.length} active Sentry-compatible account(s) configured` };
3135
3140
  }
3141
+ async function upsertPaddleAccountsConfig(configPath, accounts) {
3142
+ if (!accounts.length || !(await fileExists(configPath)))
3143
+ return false;
3144
+ const config = await readJsonFile(configPath);
3145
+ const existingAccounts = Array.isArray(config?.sources?.paddle?.accounts)
3146
+ ? config.sources.paddle.accounts
3147
+ : [];
3148
+ const merged = new Map();
3149
+ for (const account of existingAccounts) {
3150
+ const id = String(account?.id || account?.key || account?.label || '').trim();
3151
+ if (id)
3152
+ merged.set(id, account);
3153
+ }
3154
+ for (const account of accounts) {
3155
+ merged.set(account.id, {
3156
+ ...(merged.get(account.id) || {}),
3157
+ ...account,
3158
+ });
3159
+ }
3160
+ const tokenEnv = accounts[0]?.tokenEnv || config?.sources?.paddle?.tokenEnv || config?.secrets?.paddleTokenEnv || 'PADDLE_API_KEY';
3161
+ config.sources = {
3162
+ ...(config.sources || {}),
3163
+ paddle: {
3164
+ ...(config.sources?.paddle || {}),
3165
+ enabled: true,
3166
+ mode: 'command',
3167
+ command: normalizeWizardSourceCommand('paddle', config.sources?.paddle || {}, configPath),
3168
+ environment: config.sources?.paddle?.environment || 'live',
3169
+ tokenEnv,
3170
+ accounts: [...merged.values()],
3171
+ },
3172
+ };
3173
+ config.secrets = {
3174
+ ...(config.secrets || {}),
3175
+ paddleTokenEnv: tokenEnv,
3176
+ paddleTokenRef: { source: 'env', provider: 'default', id: tokenEnv },
3177
+ };
3178
+ await writeJsonFile(configPath, config);
3179
+ return true;
3180
+ }
3181
+ async function verifyPaddleAccountsConfig(configPath, expectedAccounts) {
3182
+ if (!(await fileExists(configPath))) {
3183
+ return { ok: false, detail: `${configPath} does not exist` };
3184
+ }
3185
+ const config = await readJsonFile(configPath);
3186
+ const source = config?.sources?.paddle;
3187
+ if (!source || source.enabled !== true) {
3188
+ return { ok: false, detail: 'sources.paddle.enabled is not true' };
3189
+ }
3190
+ if (source.mode !== 'command') {
3191
+ return { ok: false, detail: 'sources.paddle.mode is not command' };
3192
+ }
3193
+ const configuredAccounts = Array.isArray(source.accounts) ? source.accounts : [];
3194
+ if (configuredAccounts.length === 0) {
3195
+ return { ok: false, detail: 'sources.paddle.accounts contains no account' };
3196
+ }
3197
+ const configuredIds = new Set(configuredAccounts.map((account) => String(account?.id || account?.key || '').trim()).filter(Boolean));
3198
+ const missingIds = expectedAccounts
3199
+ .map((account) => String(account?.id || '').trim())
3200
+ .filter((id) => id && !configuredIds.has(id));
3201
+ if (missingIds.length > 0) {
3202
+ return { ok: false, detail: `sources.paddle.accounts is missing configured account id(s): ${missingIds.join(', ')}` };
3203
+ }
3204
+ return { ok: true, detail: `${configuredAccounts.length} Paddle account(s) configured` };
3205
+ }
3136
3206
  async function upsertCoolifyConfig(configPath, { baseUrl, tokenEnv = 'COOLIFY_API_TOKEN' }) {
3137
3207
  if (!(await fileExists(configPath)))
3138
3208
  return false;
@@ -3430,6 +3500,21 @@ async function guideRevenueCatConnector(rl, secrets) {
3430
3500
  if (apiKey)
3431
3501
  secrets.REVENUECAT_API_KEY = apiKey;
3432
3502
  }
3503
+ function paddleAccountIdFromLabel(label, index) {
3504
+ const normalized = String(label || '')
3505
+ .trim()
3506
+ .toLowerCase()
3507
+ .replace(/[^a-z0-9]+/g, '_')
3508
+ .replace(/^_+|_+$/g, '');
3509
+ return normalized || `paddle_${index + 1}`;
3510
+ }
3511
+ function paddleTokenEnvForAccount(index, label) {
3512
+ if (index === 0)
3513
+ return 'PADDLE_API_KEY';
3514
+ const suffix = paddleAccountIdFromLabel(label, index).toUpperCase().replace(/[^A-Z0-9]+/g, '_');
3515
+ const base = suffix && suffix !== `PADDLE_${index + 1}` ? `PADDLE_API_KEY_${suffix}` : `PADDLE_API_KEY_${index + 1}`;
3516
+ return base.replace(/_+/g, '_');
3517
+ }
3433
3518
  async function guidePaddleConnector(rl, secrets) {
3434
3519
  printSection('Paddle Billing metrics', [
3435
3520
  'Use this when OpenClaw should read web checkout, revenue, MRR, refunds, chargebacks, and active subscriber metrics.',
@@ -3438,13 +3523,34 @@ async function guidePaddleConnector(rl, secrets) {
3438
3523
  printBullets([
3439
3524
  'Open Paddle > Developer Tools > Authentication.',
3440
3525
  'Use the API keys tab and create a new live API key.',
3441
- 'Grant `metrics.read`. Do not grant write permissions unless another workflow explicitly needs them.',
3526
+ 'Minimum: grant `metrics.read` so account-level revenue, MRR, refunds, chargebacks, subscribers, and checkout conversion work.',
3527
+ 'Recommended for better Growth Engineer analysis: grant all available read-only permissions (`*.read`), including products, prices, discounts, customers, transactions, subscriptions, adjustments, reports, and notifications.',
3528
+ 'Do not grant any write permissions (`*.write`) unless another workflow explicitly needs them.',
3442
3529
  'Do not select or hard-code a single product in the wizard; the Growth Engineer should keep account-level metrics context.',
3443
3530
  'Paste the key here so it is stored only in the local chmod 600 secrets file.',
3444
3531
  ]);
3445
- const apiKey = await maybePromptSecret(rl, 'Paste PADDLE_API_KEY into this local terminal', 'PADDLE_API_KEY');
3446
- if (apiKey)
3447
- secrets.PADDLE_API_KEY = apiKey;
3532
+ const accounts = [];
3533
+ let index = 0;
3534
+ while (true) {
3535
+ const label = await ask(rl, index === 0 ? 'Paddle account label' : 'Next Paddle account label (empty = done)', index === 0 ? 'Paddle' : '');
3536
+ if (!label.trim())
3537
+ break;
3538
+ const tokenEnv = paddleTokenEnvForAccount(index, label);
3539
+ const apiKey = await maybePromptSecret(rl, `Paste ${tokenEnv} into this local terminal`, tokenEnv);
3540
+ if (apiKey)
3541
+ secrets[tokenEnv] = apiKey;
3542
+ accounts.push({
3543
+ id: paddleAccountIdFromLabel(label, index),
3544
+ label: label.trim(),
3545
+ tokenEnv,
3546
+ environment: 'live',
3547
+ });
3548
+ index += 1;
3549
+ const addAnother = await askYesNo(rl, 'Add another Paddle account?', false);
3550
+ if (!addAnother)
3551
+ break;
3552
+ }
3553
+ return accounts;
3448
3554
  }
3449
3555
  async function guideSeoConnector(rl, secrets) {
3450
3556
  printSection('SEO / Google Search Console / DataForSEO', [
@@ -3476,10 +3582,15 @@ async function guideSeoConnector(rl, secrets) {
3476
3582
  secrets.DATAFORSEO_PASSWORD = password;
3477
3583
  }
3478
3584
  }
3479
- function buildAccountSignalExtraSourceConfig(key, existing = {}) {
3585
+ function buildAccountSignalExtraSourceConfig(key, existing = {}, accounts = []) {
3480
3586
  const definition = getAccountSignalConnectorDefinition(key);
3481
3587
  if (!definition)
3482
3588
  return existing;
3589
+ const accountConfig = accounts.length > 0
3590
+ ? {
3591
+ accounts: mergeConnectorAccounts(existing.accounts, accounts),
3592
+ }
3593
+ : {};
3483
3594
  return {
3484
3595
  ...buildExtraSourceConfig(definition.service, {
3485
3596
  key: definition.key,
@@ -3503,9 +3614,28 @@ function buildAccountSignalExtraSourceConfig(key, existing = {}) {
3503
3614
  signalKind: definition.sourceKind,
3504
3615
  experimental: Boolean(definition.experimental),
3505
3616
  hint: existing.hint || definition.signalHint,
3617
+ ...accountConfig,
3506
3618
  };
3507
3619
  }
3508
- async function upsertAccountSignalConnectorConfig(configPath, key) {
3620
+ function mergeConnectorAccounts(existingAccounts, nextAccounts) {
3621
+ const merged = new Map();
3622
+ for (const account of Array.isArray(existingAccounts) ? existingAccounts : []) {
3623
+ const id = String(account?.id || account?.key || account?.label || '').trim();
3624
+ if (id)
3625
+ merged.set(id, account);
3626
+ }
3627
+ for (const account of nextAccounts) {
3628
+ const id = String(account?.id || account?.key || account?.label || '').trim();
3629
+ if (!id)
3630
+ continue;
3631
+ merged.set(id, {
3632
+ ...(merged.get(id) || {}),
3633
+ ...account,
3634
+ });
3635
+ }
3636
+ return [...merged.values()];
3637
+ }
3638
+ async function upsertAccountSignalConnectorConfig(configPath, key, accounts = []) {
3509
3639
  const definition = getAccountSignalConnectorDefinition(key);
3510
3640
  if (!definition)
3511
3641
  return false;
@@ -3514,7 +3644,7 @@ async function upsertAccountSignalConnectorConfig(configPath, key) {
3514
3644
  const extra = Array.isArray(sources.extra) ? sources.extra : [];
3515
3645
  const nextExtra = extra.filter((source) => String(source?.key || source?.service || '') !== definition.key);
3516
3646
  const existing = extra.find((source) => String(source?.key || source?.service || '') === definition.key) || {};
3517
- nextExtra.push(buildAccountSignalExtraSourceConfig(key, existing));
3647
+ nextExtra.push(buildAccountSignalExtraSourceConfig(key, existing, accounts));
3518
3648
  config.sources = {
3519
3649
  ...sources,
3520
3650
  extra: nextExtra,
@@ -3522,28 +3652,58 @@ async function upsertAccountSignalConnectorConfig(configPath, key) {
3522
3652
  await writeJsonFile(configPath, config);
3523
3653
  return true;
3524
3654
  }
3655
+ function accountSignalTokenEnvForAccount(baseEnv, key, index, label) {
3656
+ if (index === 0)
3657
+ return baseEnv;
3658
+ const suffix = toConfigId(label || key, `${key}_${index + 1}`).toUpperCase().replace(/[^A-Z0-9]+/g, '_');
3659
+ return `${baseEnv}_${suffix}`.replace(/_+/g, '_');
3660
+ }
3525
3661
  async function guideAccountSignalConnector(rl, secrets, key) {
3526
3662
  const definition = getAccountSignalConnectorDefinition(key);
3527
3663
  if (!definition)
3528
- return;
3664
+ return [];
3529
3665
  printSection(definition.label, [
3530
3666
  definition.summary,
3531
3667
  'Setup is account-wide. Do not paste project IDs, app IDs, product IDs, package names, paywall IDs, service names, or tags here.',
3532
3668
  ]);
3533
3669
  process.stdout.write(`Docs: ${definition.docsUrl}\n\n`);
3534
3670
  printBullets(definition.steps);
3535
- for (const credential of definition.credentials) {
3536
- const defaultValue = credential.defaultValue ?? process.env[credential.env] ?? '';
3537
- const value = credential.optional
3538
- ? await maybePromptSecret(rl, credential.prompt, credential.env)
3539
- : await maybePromptSecret(rl, credential.prompt, credential.env);
3540
- const finalValue = value || defaultValue;
3541
- if (finalValue)
3542
- secrets[credential.env] = finalValue;
3543
- else if (!credential.optional) {
3544
- process.stdout.write(`${credential.env} was not saved. ${definition.label} setup remains pending; rerun this wizard when ready.\n`);
3671
+ const accounts = [];
3672
+ let index = 0;
3673
+ while (true) {
3674
+ const label = await ask(rl, index === 0 ? `${definition.label} account label` : `Next ${definition.label} account label (empty = done)`, index === 0 ? definition.label.replace(/\s+\(experimental\)$/i, '') : '');
3675
+ if (!label.trim())
3676
+ break;
3677
+ const credentialEnvs = {};
3678
+ for (const credential of definition.credentials) {
3679
+ const envName = accountSignalTokenEnvForAccount(credential.env, key, index, label);
3680
+ const defaultValue = index === 0 ? credential.defaultValue ?? process.env[credential.env] ?? '' : '';
3681
+ const prompt = envName === credential.env ? credential.prompt : `${credential.prompt.replace(credential.env, envName)}`;
3682
+ const value = credential.optional
3683
+ ? await maybePromptSecret(rl, prompt, envName)
3684
+ : await maybePromptSecret(rl, prompt, envName);
3685
+ const finalValue = value || defaultValue;
3686
+ credentialEnvs[credential.env] = envName;
3687
+ if (finalValue)
3688
+ secrets[envName] = finalValue;
3689
+ else if (!credential.optional) {
3690
+ process.stdout.write(`${envName} was not saved. ${definition.label} setup remains pending for ${label}; rerun this wizard when ready.\n`);
3691
+ }
3545
3692
  }
3693
+ accounts.push({
3694
+ id: toConfigId(label, `${key}_${index + 1}`),
3695
+ label: label.trim(),
3696
+ credentialEnvs,
3697
+ tokenEnv: credentialEnvs[definition.credentials[0]?.env] || definition.credentials[0]?.env || null,
3698
+ accountWide: true,
3699
+ projectScope: 'discover_from_account',
3700
+ });
3701
+ index += 1;
3702
+ const addAnother = await askYesNo(rl, `Add another ${definition.label} account?`, false);
3703
+ if (!addAnother)
3704
+ break;
3546
3705
  }
3706
+ return accounts;
3547
3707
  }
3548
3708
  async function guideSentryConnector(rl, secrets) {
3549
3709
  printSection('Sentry / GlitchTip', [
@@ -3852,6 +4012,7 @@ async function runConnectorSetupSteps({ rl, args, selected, healthByConnector, a
3852
4012
  process.stdout.write('\n');
3853
4013
  const secrets = {};
3854
4014
  let sentryAccounts = [];
4015
+ let paddleAccounts = [];
3855
4016
  let coolifyConfig = null;
3856
4017
  if (selected.includes('analytics')) {
3857
4018
  let forceFreshAnalyticsToken = shouldForceFreshAnalyticsToken(healthByConnector);
@@ -3900,12 +4061,13 @@ async function runConnectorSetupSteps({ rl, args, selected, healthByConnector, a
3900
4061
  if (selected.includes('paddle')) {
3901
4062
  while (true) {
3902
4063
  clearTerminal();
3903
- await guidePaddleConnector(rl, secrets);
4064
+ paddleAccounts = await guidePaddleConnector(rl, secrets);
3904
4065
  const check = await runImmediateConnectorHealthCheck({
3905
4066
  rl,
3906
4067
  configPath: args.config,
3907
4068
  connector: 'paddle',
3908
4069
  secrets,
4070
+ paddleAccounts,
3909
4071
  });
3910
4072
  if (!check.retry)
3911
4073
  break;
@@ -3974,8 +4136,8 @@ async function runConnectorSetupSteps({ rl, args, selected, healthByConnector, a
3974
4136
  for (const connector of selected.filter(isAccountSignalConnector)) {
3975
4137
  while (true) {
3976
4138
  clearTerminal();
3977
- await guideAccountSignalConnector(rl, secrets, connector);
3978
- await upsertAccountSignalConnectorConfig(args.config, connector);
4139
+ const accountSignalAccounts = await guideAccountSignalConnector(rl, secrets, connector);
4140
+ await upsertAccountSignalConnectorConfig(args.config, connector, accountSignalAccounts);
3979
4141
  const check = await runImmediateConnectorHealthCheck({
3980
4142
  rl,
3981
4143
  configPath: args.config,
@@ -4002,6 +4164,12 @@ async function runConnectorSetupSteps({ rl, args, selected, healthByConnector, a
4002
4164
  process.stdout.write(`Configured ${sentryAccounts.length} Sentry-compatible account(s) in ${args.config}.\n`);
4003
4165
  }
4004
4166
  }
4167
+ if (paddleAccounts.length > 0 && await upsertPaddleAccountsConfig(args.config, paddleAccounts)) {
4168
+ const readiness = await verifyPaddleAccountsConfig(args.config, paddleAccounts);
4169
+ if (readiness.ok) {
4170
+ process.stdout.write(`Configured ${paddleAccounts.length} Paddle account(s) in ${args.config}.\n`);
4171
+ }
4172
+ }
4005
4173
  if (coolifyConfig?.baseUrl && await upsertCoolifyConfig(args.config, coolifyConfig)) {
4006
4174
  process.stdout.write(`Configured Coolify monitoring for ${coolifyConfig.baseUrl} in ${args.config}.\n`);
4007
4175
  }
@@ -4026,6 +4194,19 @@ async function runConnectorSetupSteps({ rl, args, selected, healthByConnector, a
4026
4194
  });
4027
4195
  }
4028
4196
  }
4197
+ if (paddleAccounts.length > 0 && await upsertPaddleAccountsConfig(args.config, paddleAccounts)) {
4198
+ const readiness = await verifyPaddleAccountsConfig(args.config, paddleAccounts);
4199
+ if (readiness.ok) {
4200
+ process.stdout.write(`Paddle account config is up to date in ${args.config}.\n`);
4201
+ }
4202
+ else {
4203
+ postSetupBlockers.push({
4204
+ check: 'connection:paddle',
4205
+ detail: readiness.detail,
4206
+ remediation: 'Rerun Paddle setup so the active config persists sources.paddle.enabled=true and sources.paddle.accounts[].',
4207
+ });
4208
+ }
4209
+ }
4029
4210
  if (coolifyConfig?.baseUrl && await upsertCoolifyConfig(args.config, coolifyConfig)) {
4030
4211
  process.stdout.write(`Coolify config is up to date in ${args.config}.\n`);
4031
4212
  }