@analyticscli/growth-engineer 0.1.1-preview.5 → 0.1.1-preview.7

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.
@@ -84,7 +84,7 @@ const CONNECTOR_DEFINITIONS = [
84
84
  key: 'paddle',
85
85
  label: 'Paddle Billing metrics',
86
86
  summary: 'Read web checkout, revenue, MRR, refunds, chargebacks, and active subscriber metrics.',
87
- needs: 'A Paddle API key for the live account. Paddle may not expose per-key scope selection in every vendor UI.',
87
+ needs: 'A scoped Paddle API key for the live account with metrics.read permission.',
88
88
  },
89
89
  {
90
90
  key: 'seo',
@@ -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'));
@@ -2075,7 +2077,7 @@ function summarizeFailureFix(connector, blockers) {
2075
2077
  return 'Paste a RevenueCat v2 secret API key with read-only project permissions, then rerun setup.';
2076
2078
  }
2077
2079
  if (connector === 'paddle') {
2078
- return 'Paste a live Paddle API key from Developer Tools > Authentication, then rerun setup. Paddle may not expose per-key scope selection in every vendor UI.';
2080
+ return 'Paste a live Paddle API key from Developer Tools > Authentication v2 with metrics.read permission, then rerun setup.';
2079
2081
  }
2080
2082
  if (connector === 'seo') {
2081
2083
  return 'Configure Search Console read access. Leave GSC_SITE_URL empty to scan all verified properties in the account, or set it only when you intentionally want one property.';
@@ -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,22 +3500,55 @@ 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.',
3436
3521
  ]);
3437
- process.stdout.write('\nCreate or update a Paddle API key here:\n https://vendors.paddle.com/authentication\n\n');
3522
+ process.stdout.write('\nCreate or update a scoped Paddle API key here:\n https://vendors.paddle.com/authentication-v2\n\n');
3438
3523
  printBullets([
3439
3524
  'Open Paddle > Developer Tools > Authentication.',
3440
- 'Create a new API key for the live account when you want production revenue evidence.',
3441
- 'If your Paddle UI offers API-key permissions, grant only the permissions needed for metrics/revenue reads.',
3442
- 'If your Paddle UI does not offer scope selection, use a dedicated Growth Engineer key and rotate/revoke it from Paddle when needed.',
3525
+ 'Use the API keys tab and create a new live API key.',
3526
+ 'Grant `metrics.read`. Do not grant write permissions unless another workflow explicitly needs them.',
3443
3527
  'Do not select or hard-code a single product in the wizard; the Growth Engineer should keep account-level metrics context.',
3444
3528
  'Paste the key here so it is stored only in the local chmod 600 secrets file.',
3445
3529
  ]);
3446
- const apiKey = await maybePromptSecret(rl, 'Paste PADDLE_API_KEY into this local terminal', 'PADDLE_API_KEY');
3447
- if (apiKey)
3448
- secrets.PADDLE_API_KEY = apiKey;
3530
+ const accounts = [];
3531
+ let index = 0;
3532
+ while (true) {
3533
+ const label = await ask(rl, index === 0 ? 'Paddle account label' : 'Next Paddle account label (empty = done)', index === 0 ? 'Paddle' : '');
3534
+ if (!label.trim())
3535
+ break;
3536
+ const tokenEnv = paddleTokenEnvForAccount(index, label);
3537
+ const apiKey = await maybePromptSecret(rl, `Paste ${tokenEnv} into this local terminal`, tokenEnv);
3538
+ if (apiKey)
3539
+ secrets[tokenEnv] = apiKey;
3540
+ accounts.push({
3541
+ id: paddleAccountIdFromLabel(label, index),
3542
+ label: label.trim(),
3543
+ tokenEnv,
3544
+ environment: 'live',
3545
+ });
3546
+ index += 1;
3547
+ const addAnother = await askYesNo(rl, 'Add another Paddle account?', false);
3548
+ if (!addAnother)
3549
+ break;
3550
+ }
3551
+ return accounts;
3449
3552
  }
3450
3553
  async function guideSeoConnector(rl, secrets) {
3451
3554
  printSection('SEO / Google Search Console / DataForSEO', [
@@ -3477,10 +3580,15 @@ async function guideSeoConnector(rl, secrets) {
3477
3580
  secrets.DATAFORSEO_PASSWORD = password;
3478
3581
  }
3479
3582
  }
3480
- function buildAccountSignalExtraSourceConfig(key, existing = {}) {
3583
+ function buildAccountSignalExtraSourceConfig(key, existing = {}, accounts = []) {
3481
3584
  const definition = getAccountSignalConnectorDefinition(key);
3482
3585
  if (!definition)
3483
3586
  return existing;
3587
+ const accountConfig = accounts.length > 0
3588
+ ? {
3589
+ accounts: mergeConnectorAccounts(existing.accounts, accounts),
3590
+ }
3591
+ : {};
3484
3592
  return {
3485
3593
  ...buildExtraSourceConfig(definition.service, {
3486
3594
  key: definition.key,
@@ -3504,9 +3612,28 @@ function buildAccountSignalExtraSourceConfig(key, existing = {}) {
3504
3612
  signalKind: definition.sourceKind,
3505
3613
  experimental: Boolean(definition.experimental),
3506
3614
  hint: existing.hint || definition.signalHint,
3615
+ ...accountConfig,
3507
3616
  };
3508
3617
  }
3509
- async function upsertAccountSignalConnectorConfig(configPath, key) {
3618
+ function mergeConnectorAccounts(existingAccounts, nextAccounts) {
3619
+ const merged = new Map();
3620
+ for (const account of Array.isArray(existingAccounts) ? existingAccounts : []) {
3621
+ const id = String(account?.id || account?.key || account?.label || '').trim();
3622
+ if (id)
3623
+ merged.set(id, account);
3624
+ }
3625
+ for (const account of nextAccounts) {
3626
+ const id = String(account?.id || account?.key || account?.label || '').trim();
3627
+ if (!id)
3628
+ continue;
3629
+ merged.set(id, {
3630
+ ...(merged.get(id) || {}),
3631
+ ...account,
3632
+ });
3633
+ }
3634
+ return [...merged.values()];
3635
+ }
3636
+ async function upsertAccountSignalConnectorConfig(configPath, key, accounts = []) {
3510
3637
  const definition = getAccountSignalConnectorDefinition(key);
3511
3638
  if (!definition)
3512
3639
  return false;
@@ -3515,7 +3642,7 @@ async function upsertAccountSignalConnectorConfig(configPath, key) {
3515
3642
  const extra = Array.isArray(sources.extra) ? sources.extra : [];
3516
3643
  const nextExtra = extra.filter((source) => String(source?.key || source?.service || '') !== definition.key);
3517
3644
  const existing = extra.find((source) => String(source?.key || source?.service || '') === definition.key) || {};
3518
- nextExtra.push(buildAccountSignalExtraSourceConfig(key, existing));
3645
+ nextExtra.push(buildAccountSignalExtraSourceConfig(key, existing, accounts));
3519
3646
  config.sources = {
3520
3647
  ...sources,
3521
3648
  extra: nextExtra,
@@ -3523,28 +3650,58 @@ async function upsertAccountSignalConnectorConfig(configPath, key) {
3523
3650
  await writeJsonFile(configPath, config);
3524
3651
  return true;
3525
3652
  }
3653
+ function accountSignalTokenEnvForAccount(baseEnv, key, index, label) {
3654
+ if (index === 0)
3655
+ return baseEnv;
3656
+ const suffix = toConfigId(label || key, `${key}_${index + 1}`).toUpperCase().replace(/[^A-Z0-9]+/g, '_');
3657
+ return `${baseEnv}_${suffix}`.replace(/_+/g, '_');
3658
+ }
3526
3659
  async function guideAccountSignalConnector(rl, secrets, key) {
3527
3660
  const definition = getAccountSignalConnectorDefinition(key);
3528
3661
  if (!definition)
3529
- return;
3662
+ return [];
3530
3663
  printSection(definition.label, [
3531
3664
  definition.summary,
3532
3665
  'Setup is account-wide. Do not paste project IDs, app IDs, product IDs, package names, paywall IDs, service names, or tags here.',
3533
3666
  ]);
3534
3667
  process.stdout.write(`Docs: ${definition.docsUrl}\n\n`);
3535
3668
  printBullets(definition.steps);
3536
- for (const credential of definition.credentials) {
3537
- const defaultValue = credential.defaultValue ?? process.env[credential.env] ?? '';
3538
- const value = credential.optional
3539
- ? await maybePromptSecret(rl, credential.prompt, credential.env)
3540
- : await maybePromptSecret(rl, credential.prompt, credential.env);
3541
- const finalValue = value || defaultValue;
3542
- if (finalValue)
3543
- secrets[credential.env] = finalValue;
3544
- else if (!credential.optional) {
3545
- process.stdout.write(`${credential.env} was not saved. ${definition.label} setup remains pending; rerun this wizard when ready.\n`);
3669
+ const accounts = [];
3670
+ let index = 0;
3671
+ while (true) {
3672
+ 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, '') : '');
3673
+ if (!label.trim())
3674
+ break;
3675
+ const credentialEnvs = {};
3676
+ for (const credential of definition.credentials) {
3677
+ const envName = accountSignalTokenEnvForAccount(credential.env, key, index, label);
3678
+ const defaultValue = index === 0 ? credential.defaultValue ?? process.env[credential.env] ?? '' : '';
3679
+ const prompt = envName === credential.env ? credential.prompt : `${credential.prompt.replace(credential.env, envName)}`;
3680
+ const value = credential.optional
3681
+ ? await maybePromptSecret(rl, prompt, envName)
3682
+ : await maybePromptSecret(rl, prompt, envName);
3683
+ const finalValue = value || defaultValue;
3684
+ credentialEnvs[credential.env] = envName;
3685
+ if (finalValue)
3686
+ secrets[envName] = finalValue;
3687
+ else if (!credential.optional) {
3688
+ process.stdout.write(`${envName} was not saved. ${definition.label} setup remains pending for ${label}; rerun this wizard when ready.\n`);
3689
+ }
3546
3690
  }
3691
+ accounts.push({
3692
+ id: toConfigId(label, `${key}_${index + 1}`),
3693
+ label: label.trim(),
3694
+ credentialEnvs,
3695
+ tokenEnv: credentialEnvs[definition.credentials[0]?.env] || definition.credentials[0]?.env || null,
3696
+ accountWide: true,
3697
+ projectScope: 'discover_from_account',
3698
+ });
3699
+ index += 1;
3700
+ const addAnother = await askYesNo(rl, `Add another ${definition.label} account?`, false);
3701
+ if (!addAnother)
3702
+ break;
3547
3703
  }
3704
+ return accounts;
3548
3705
  }
3549
3706
  async function guideSentryConnector(rl, secrets) {
3550
3707
  printSection('Sentry / GlitchTip', [
@@ -3853,6 +4010,7 @@ async function runConnectorSetupSteps({ rl, args, selected, healthByConnector, a
3853
4010
  process.stdout.write('\n');
3854
4011
  const secrets = {};
3855
4012
  let sentryAccounts = [];
4013
+ let paddleAccounts = [];
3856
4014
  let coolifyConfig = null;
3857
4015
  if (selected.includes('analytics')) {
3858
4016
  let forceFreshAnalyticsToken = shouldForceFreshAnalyticsToken(healthByConnector);
@@ -3901,12 +4059,13 @@ async function runConnectorSetupSteps({ rl, args, selected, healthByConnector, a
3901
4059
  if (selected.includes('paddle')) {
3902
4060
  while (true) {
3903
4061
  clearTerminal();
3904
- await guidePaddleConnector(rl, secrets);
4062
+ paddleAccounts = await guidePaddleConnector(rl, secrets);
3905
4063
  const check = await runImmediateConnectorHealthCheck({
3906
4064
  rl,
3907
4065
  configPath: args.config,
3908
4066
  connector: 'paddle',
3909
4067
  secrets,
4068
+ paddleAccounts,
3910
4069
  });
3911
4070
  if (!check.retry)
3912
4071
  break;
@@ -3975,8 +4134,8 @@ async function runConnectorSetupSteps({ rl, args, selected, healthByConnector, a
3975
4134
  for (const connector of selected.filter(isAccountSignalConnector)) {
3976
4135
  while (true) {
3977
4136
  clearTerminal();
3978
- await guideAccountSignalConnector(rl, secrets, connector);
3979
- await upsertAccountSignalConnectorConfig(args.config, connector);
4137
+ const accountSignalAccounts = await guideAccountSignalConnector(rl, secrets, connector);
4138
+ await upsertAccountSignalConnectorConfig(args.config, connector, accountSignalAccounts);
3980
4139
  const check = await runImmediateConnectorHealthCheck({
3981
4140
  rl,
3982
4141
  configPath: args.config,
@@ -4003,6 +4162,12 @@ async function runConnectorSetupSteps({ rl, args, selected, healthByConnector, a
4003
4162
  process.stdout.write(`Configured ${sentryAccounts.length} Sentry-compatible account(s) in ${args.config}.\n`);
4004
4163
  }
4005
4164
  }
4165
+ if (paddleAccounts.length > 0 && await upsertPaddleAccountsConfig(args.config, paddleAccounts)) {
4166
+ const readiness = await verifyPaddleAccountsConfig(args.config, paddleAccounts);
4167
+ if (readiness.ok) {
4168
+ process.stdout.write(`Configured ${paddleAccounts.length} Paddle account(s) in ${args.config}.\n`);
4169
+ }
4170
+ }
4006
4171
  if (coolifyConfig?.baseUrl && await upsertCoolifyConfig(args.config, coolifyConfig)) {
4007
4172
  process.stdout.write(`Configured Coolify monitoring for ${coolifyConfig.baseUrl} in ${args.config}.\n`);
4008
4173
  }
@@ -4027,6 +4192,19 @@ async function runConnectorSetupSteps({ rl, args, selected, healthByConnector, a
4027
4192
  });
4028
4193
  }
4029
4194
  }
4195
+ if (paddleAccounts.length > 0 && await upsertPaddleAccountsConfig(args.config, paddleAccounts)) {
4196
+ const readiness = await verifyPaddleAccountsConfig(args.config, paddleAccounts);
4197
+ if (readiness.ok) {
4198
+ process.stdout.write(`Paddle account config is up to date in ${args.config}.\n`);
4199
+ }
4200
+ else {
4201
+ postSetupBlockers.push({
4202
+ check: 'connection:paddle',
4203
+ detail: readiness.detail,
4204
+ remediation: 'Rerun Paddle setup so the active config persists sources.paddle.enabled=true and sources.paddle.accounts[].',
4205
+ });
4206
+ }
4207
+ }
4030
4208
  if (coolifyConfig?.baseUrl && await upsertCoolifyConfig(args.config, coolifyConfig)) {
4031
4209
  process.stdout.write(`Coolify config is up to date in ${args.config}.\n`);
4032
4210
  }