@analyticscli/growth-engineer 0.1.1-preview.6 → 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.
@@ -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.',
@@ -3442,9 +3527,28 @@ async function guidePaddleConnector(rl, secrets) {
3442
3527
  'Do not select or hard-code a single product in the wizard; the Growth Engineer should keep account-level metrics context.',
3443
3528
  'Paste the key here so it is stored only in the local chmod 600 secrets file.',
3444
3529
  ]);
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;
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;
3448
3552
  }
3449
3553
  async function guideSeoConnector(rl, secrets) {
3450
3554
  printSection('SEO / Google Search Console / DataForSEO', [
@@ -3476,10 +3580,15 @@ async function guideSeoConnector(rl, secrets) {
3476
3580
  secrets.DATAFORSEO_PASSWORD = password;
3477
3581
  }
3478
3582
  }
3479
- function buildAccountSignalExtraSourceConfig(key, existing = {}) {
3583
+ function buildAccountSignalExtraSourceConfig(key, existing = {}, accounts = []) {
3480
3584
  const definition = getAccountSignalConnectorDefinition(key);
3481
3585
  if (!definition)
3482
3586
  return existing;
3587
+ const accountConfig = accounts.length > 0
3588
+ ? {
3589
+ accounts: mergeConnectorAccounts(existing.accounts, accounts),
3590
+ }
3591
+ : {};
3483
3592
  return {
3484
3593
  ...buildExtraSourceConfig(definition.service, {
3485
3594
  key: definition.key,
@@ -3503,9 +3612,28 @@ function buildAccountSignalExtraSourceConfig(key, existing = {}) {
3503
3612
  signalKind: definition.sourceKind,
3504
3613
  experimental: Boolean(definition.experimental),
3505
3614
  hint: existing.hint || definition.signalHint,
3615
+ ...accountConfig,
3506
3616
  };
3507
3617
  }
3508
- 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 = []) {
3509
3637
  const definition = getAccountSignalConnectorDefinition(key);
3510
3638
  if (!definition)
3511
3639
  return false;
@@ -3514,7 +3642,7 @@ async function upsertAccountSignalConnectorConfig(configPath, key) {
3514
3642
  const extra = Array.isArray(sources.extra) ? sources.extra : [];
3515
3643
  const nextExtra = extra.filter((source) => String(source?.key || source?.service || '') !== definition.key);
3516
3644
  const existing = extra.find((source) => String(source?.key || source?.service || '') === definition.key) || {};
3517
- nextExtra.push(buildAccountSignalExtraSourceConfig(key, existing));
3645
+ nextExtra.push(buildAccountSignalExtraSourceConfig(key, existing, accounts));
3518
3646
  config.sources = {
3519
3647
  ...sources,
3520
3648
  extra: nextExtra,
@@ -3522,28 +3650,58 @@ async function upsertAccountSignalConnectorConfig(configPath, key) {
3522
3650
  await writeJsonFile(configPath, config);
3523
3651
  return true;
3524
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
+ }
3525
3659
  async function guideAccountSignalConnector(rl, secrets, key) {
3526
3660
  const definition = getAccountSignalConnectorDefinition(key);
3527
3661
  if (!definition)
3528
- return;
3662
+ return [];
3529
3663
  printSection(definition.label, [
3530
3664
  definition.summary,
3531
3665
  'Setup is account-wide. Do not paste project IDs, app IDs, product IDs, package names, paywall IDs, service names, or tags here.',
3532
3666
  ]);
3533
3667
  process.stdout.write(`Docs: ${definition.docsUrl}\n\n`);
3534
3668
  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`);
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
+ }
3545
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;
3546
3703
  }
3704
+ return accounts;
3547
3705
  }
3548
3706
  async function guideSentryConnector(rl, secrets) {
3549
3707
  printSection('Sentry / GlitchTip', [
@@ -3852,6 +4010,7 @@ async function runConnectorSetupSteps({ rl, args, selected, healthByConnector, a
3852
4010
  process.stdout.write('\n');
3853
4011
  const secrets = {};
3854
4012
  let sentryAccounts = [];
4013
+ let paddleAccounts = [];
3855
4014
  let coolifyConfig = null;
3856
4015
  if (selected.includes('analytics')) {
3857
4016
  let forceFreshAnalyticsToken = shouldForceFreshAnalyticsToken(healthByConnector);
@@ -3900,12 +4059,13 @@ async function runConnectorSetupSteps({ rl, args, selected, healthByConnector, a
3900
4059
  if (selected.includes('paddle')) {
3901
4060
  while (true) {
3902
4061
  clearTerminal();
3903
- await guidePaddleConnector(rl, secrets);
4062
+ paddleAccounts = await guidePaddleConnector(rl, secrets);
3904
4063
  const check = await runImmediateConnectorHealthCheck({
3905
4064
  rl,
3906
4065
  configPath: args.config,
3907
4066
  connector: 'paddle',
3908
4067
  secrets,
4068
+ paddleAccounts,
3909
4069
  });
3910
4070
  if (!check.retry)
3911
4071
  break;
@@ -3974,8 +4134,8 @@ async function runConnectorSetupSteps({ rl, args, selected, healthByConnector, a
3974
4134
  for (const connector of selected.filter(isAccountSignalConnector)) {
3975
4135
  while (true) {
3976
4136
  clearTerminal();
3977
- await guideAccountSignalConnector(rl, secrets, connector);
3978
- await upsertAccountSignalConnectorConfig(args.config, connector);
4137
+ const accountSignalAccounts = await guideAccountSignalConnector(rl, secrets, connector);
4138
+ await upsertAccountSignalConnectorConfig(args.config, connector, accountSignalAccounts);
3979
4139
  const check = await runImmediateConnectorHealthCheck({
3980
4140
  rl,
3981
4141
  configPath: args.config,
@@ -4002,6 +4162,12 @@ async function runConnectorSetupSteps({ rl, args, selected, healthByConnector, a
4002
4162
  process.stdout.write(`Configured ${sentryAccounts.length} Sentry-compatible account(s) in ${args.config}.\n`);
4003
4163
  }
4004
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
+ }
4005
4171
  if (coolifyConfig?.baseUrl && await upsertCoolifyConfig(args.config, coolifyConfig)) {
4006
4172
  process.stdout.write(`Configured Coolify monitoring for ${coolifyConfig.baseUrl} in ${args.config}.\n`);
4007
4173
  }
@@ -4026,6 +4192,19 @@ async function runConnectorSetupSteps({ rl, args, selected, healthByConnector, a
4026
4192
  });
4027
4193
  }
4028
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
+ }
4029
4208
  if (coolifyConfig?.baseUrl && await upsertCoolifyConfig(args.config, coolifyConfig)) {
4030
4209
  process.stdout.write(`Coolify config is up to date in ${args.config}.\n`);
4031
4210
  }