@analyticscli/growth-engineer 0.1.1-preview.15 → 0.1.1-preview.18

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.
@@ -108,7 +108,7 @@ const CONNECTOR_DEFINITIONS = [
108
108
  key: 'asc',
109
109
  label: 'ASC / App Store Connect CLI',
110
110
  summary: 'Read App Store analytics, reviews/ratings, builds/TestFlight/release context, subscriptions, purchases, and crash totals.',
111
- needs: 'ASC_KEY_ID, ASC_ISSUER_ID, and the AuthKey_XXXX.p8 content or path.',
111
+ needs: 'Two App Store Connect API keys: a Sales and Reports or Finance key for ongoing use, plus a temporary Admin key for one-time analytics bootstrap.',
112
112
  },
113
113
  {
114
114
  key: 'stripe',
@@ -2535,7 +2535,7 @@ async function saveSecretsImmediately(secrets) {
2535
2535
  process.stdout.write(`Saved local secrets to ${secretsFile} with chmod 600.\n`);
2536
2536
  return true;
2537
2537
  }
2538
- async function runImmediateConnectorHealthCheck({ rl, configPath, connector, secrets, sentryAccounts = [], paddleAccounts = [], }) {
2538
+ async function runImmediateConnectorHealthCheck({ rl, configPath, connector, secrets, runtimeEnv = {}, sentryAccounts = [], paddleAccounts = [], }) {
2539
2539
  if (connector === 'sentry' && sentryAccounts.length > 0) {
2540
2540
  await upsertSentryAccountsConfig(configPath, sentryAccounts);
2541
2541
  }
@@ -2546,6 +2546,7 @@ async function runImmediateConnectorHealthCheck({ rl, configPath, connector, sec
2546
2546
  const env = {
2547
2547
  ...process.env,
2548
2548
  ...secrets,
2549
+ ...runtimeEnv,
2549
2550
  };
2550
2551
  const command = `${nodeRuntimeScriptCommand('openclaw-growth-start.mjs')} --config ${quote(configPath)} --setup-only --connectors ${quote(connector)} --only-connectors ${quote(connector)}`;
2551
2552
  let result = await runSetupCommandWithProgress(command, env, [connector], `Checking ${connectorLabel(connector)} immediately after setup...`);
@@ -2686,12 +2687,17 @@ function resolveSecretsFile() {
2686
2687
  return path.join(process.env.HOME, '.config', 'openclaw-growth', 'secrets.env');
2687
2688
  return path.resolve('.openclaw-growth-secrets.env');
2688
2689
  }
2689
- function resolveAscPrivateKeyPath(keyId) {
2690
+ function resolveAscPrivateKeyPath(keyId, suffix = '') {
2690
2691
  const safeKeyId = (keyId || 'OPENCLAW').trim().replace(/[^a-zA-Z0-9_-]/g, '_') || 'OPENCLAW';
2691
2692
  const baseDir = process.env.HOME
2692
2693
  ? path.join(process.env.HOME, '.config', 'openclaw-growth')
2693
2694
  : path.resolve('.openclaw-growth');
2694
- return path.join(baseDir, `AuthKey_${safeKeyId}.p8`);
2695
+ return path.join(baseDir, `AuthKey_${safeKeyId}${suffix}.p8`);
2696
+ }
2697
+ function inferAscKeyIdFromPrivateKeyPath(filePath) {
2698
+ const fileName = path.basename(String(filePath || '').trim());
2699
+ const match = fileName.match(/^AuthKey_([A-Za-z0-9]+)\.p8$/);
2700
+ return match?.[1] || '';
2695
2701
  }
2696
2702
  function renderEnvValue(value) {
2697
2703
  return `"${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$')}"`;
@@ -3384,11 +3390,14 @@ function validateAscPrivateKeyContent(value) {
3384
3390
  };
3385
3391
  }
3386
3392
  }
3387
- async function askAscPrivateKeyContent(rl) {
3388
- process.stdout.write('\nPaste the full .p8 file content here. Leave the first line empty if you already saved the .p8 file on this host.\n');
3389
- process.stdout.write('The wizard validates the pasted key, stores it locally with chmod 600, and only saves ASC_PRIVATE_KEY_PATH.\n');
3393
+ async function askAscPrivateKeyContent(rl, options = {}) {
3394
+ const envName = options.envName || 'ASC_PRIVATE_KEY';
3395
+ const persistLabel = options.persistLabel || 'ASC_PRIVATE_KEY_PATH';
3396
+ const keyLabel = options.keyLabel || 'this App Store Connect key';
3397
+ process.stdout.write(`\nPaste the full .p8 file content for ${keyLabel}.\nLeave the first line empty if the .p8 file is already saved on this host.\n`);
3398
+ process.stdout.write(`The wizard validates the pasted key, stores it locally with chmod 600, and only saves ${persistLabel}.\n`);
3390
3399
  while (true) {
3391
- const value = await readAscPrivateKeyPaste(rl);
3400
+ const value = await readAscPrivateKeyPaste(rl, envName);
3392
3401
  if (!value.trim())
3393
3402
  return '';
3394
3403
  const validation = validateAscPrivateKeyContent(value);
@@ -3398,7 +3407,7 @@ async function askAscPrivateKeyContent(rl) {
3398
3407
  process.stdout.write('The .p8 was not saved. Paste the full file again from BEGIN to END, or leave empty to use a path.\n');
3399
3408
  }
3400
3409
  }
3401
- async function readAscPrivateKeyPaste(rl) {
3410
+ async function readAscPrivateKeyPaste(rl, envName = 'ASC_PRIVATE_KEY') {
3402
3411
  return await new Promise((resolve, reject) => {
3403
3412
  let buffer = '';
3404
3413
  let settled = false;
@@ -3459,7 +3468,7 @@ async function readAscPrivateKeyPaste(rl) {
3459
3468
  process.stdin.setEncoding('utf8');
3460
3469
  process.stdin.on('data', onData);
3461
3470
  process.stdin.on('error', onError);
3462
- process.stdout.write('ASC_PRIVATE_KEY content: ');
3471
+ process.stdout.write(`${envName} content: `);
3463
3472
  process.stdin.resume();
3464
3473
  });
3465
3474
  }
@@ -3467,9 +3476,11 @@ async function validateAscPrivateKeyPath(filePath) {
3467
3476
  const raw = await fs.readFile(filePath, 'utf8');
3468
3477
  return validateAscPrivateKeyContent(raw);
3469
3478
  }
3470
- async function askAscPrivateKeyPath(rl) {
3479
+ async function askAscPrivateKeyPath(rl, options = {}) {
3480
+ const label = options.label || 'ASC_PRIVATE_KEY_PATH (path to AuthKey_XXXX.p8, leave empty to skip)';
3481
+ const defaultValue = options.defaultValue ?? process.env.ASC_PRIVATE_KEY_PATH ?? '';
3471
3482
  while (true) {
3472
- const privateKeyPath = await ask(rl, 'ASC_PRIVATE_KEY_PATH (path to AuthKey_XXXX.p8, leave empty to skip)', process.env.ASC_PRIVATE_KEY_PATH || '');
3483
+ const privateKeyPath = await ask(rl, label, defaultValue);
3473
3484
  const trimmedPath = privateKeyPath.trim();
3474
3485
  if (!trimmedPath)
3475
3486
  return '';
@@ -3485,6 +3496,19 @@ async function askAscPrivateKeyPath(rl) {
3485
3496
  process.stdout.write('The ASC private key path was not saved. Paste a valid path, or leave empty to skip.\n');
3486
3497
  }
3487
3498
  }
3499
+ async function askAscPrivateKeyPathWithKeyId(rl, options = {}) {
3500
+ const keyLabel = options.keyLabel || 'App Store Connect key';
3501
+ while (true) {
3502
+ const privateKeyPath = await askAscPrivateKeyPath(rl, options);
3503
+ if (!privateKeyPath)
3504
+ return { privateKeyPath: '', keyId: '' };
3505
+ const keyId = inferAscKeyIdFromPrivateKeyPath(privateKeyPath);
3506
+ if (keyId)
3507
+ return { privateKeyPath, keyId };
3508
+ process.stdout.write(`Could not infer Key ID for ${keyLabel} from the .p8 file name.\n`);
3509
+ process.stdout.write('Use Apple\'s original downloaded file name: AuthKey_<KEY_ID>.p8. Do not rename the .p8 file.\n');
3510
+ }
3511
+ }
3488
3512
  function printSection(title, lines = []) {
3489
3513
  process.stdout.write(`\n${ANSI.bold}${title}${ANSI.reset}\n`);
3490
3514
  process.stdout.write(`${'-'.repeat(title.length)}\n`);
@@ -3921,41 +3945,58 @@ async function guideCoolifyConnector(rl, secrets) {
3921
3945
  }
3922
3946
  async function guideAscConnector(rl, secrets) {
3923
3947
  printSection('App Store Connect CLI', [
3924
- 'Use this mainly for App Store analytics batch reports, plus builds, TestFlight, reviews, ratings, and store context.',
3925
- 'The normal Growth Engineer path uses App Store Connect API-key reports. Experimental ASC web analytics is not part of setup.',
3948
+ 'You need two App Store Connect API keys: one normal reporting key and one temporary Admin key.',
3949
+ 'Create both here: https://appstoreconnect.apple.com/access/integrations/api',
3926
3950
  ]);
3927
- process.stdout.write('Create an App Store Connect API key here:\n https://appstoreconnect.apple.com/access/integrations/api\n\n');
3928
- process.stdout.write('Roles to choose for this key:\n');
3951
+ process.stdout.write('\nStep 1 - normal key, saved for Growth Engineer\n');
3929
3952
  printBullets([
3930
- 'Required for first setup: Admin, because Apple only allows Admin keys to create the initial Analytics Report Request.',
3931
- 'Required for steady-state report downloads after the request exists: Sales and Reports, Finance, or Admin.',
3932
- 'Recommended: Customer Support, for App Store ratings and review text.',
3933
- 'Recommended: Developer, for builds, TestFlight, and delivery status.',
3934
- 'Optional: App Manager, only if OpenClaw should also read or manage app metadata, pricing, or release settings.',
3935
- 'Least privilege option: run setup once with Admin, then rotate Growth Engineer to a Sales and Reports key for ongoing analytics downloads.',
3953
+ 'Create an API key named "Growth Engineer Reports".',
3954
+ 'Role: Sales and Reports. Finance or Admin also works.',
3955
+ 'Optional extra roles: Customer Support for reviews, Developer for builds/TestFlight.',
3956
+ 'Copy this key into the ASC_KEY_ID, ASC_ISSUER_ID, and ASC_PRIVATE_KEY prompts below.',
3936
3957
  ]);
3937
- process.stdout.write('\nWhy Admin is requested during setup:\n');
3958
+ process.stdout.write('\nStep 2 - temporary Admin key, used once\n');
3938
3959
  printBullets([
3939
- 'Growth Engineer automatically creates an ongoing App Analytics report request when none exists.',
3940
- 'Without that request, Apple will not generate Impressions, Product Page Views, App Units, Conversion Rate, and related report instances.',
3941
- 'A non-Admin key can read existing reports, but creation fails with a forbidden response.',
3960
+ 'Create a second API key named "Growth Engineer Setup Admin".',
3961
+ 'Role: Admin.',
3962
+ 'Use it only when the wizard asks for ASC_BOOTSTRAP_* later.',
3963
+ 'The wizard does not save this key to secrets.env. Revoke it in App Store Connect after setup.',
3942
3964
  ]);
3943
- process.stdout.write('\nAfter creating the key, copy these values into this wizard:\n');
3965
+ process.stdout.write('\nWhy two keys?\n');
3944
3966
  printBullets([
3945
- 'Issuer ID from the API keys page.',
3946
- 'Key ID from the API key row or from the downloaded file name: AuthKey_<KEY_ID>.p8.',
3947
- 'Download the .p8 file, open it, then paste the full file content into this terminal.',
3948
- 'If the .p8 is already on this host, leave the content prompt empty and paste the file path instead.',
3949
- 'Vendor Number from App Store Connect Sales and Trends > Reports. Required for healthy ASC status because Sales and Trends/App Units depend on it.',
3967
+ 'Apple requires Admin once to create the initial App Analytics report request.',
3968
+ 'After that, Growth Engineer only needs the normal reporting key for downloads.',
3950
3969
  ]);
3951
- const keyId = await ask(rl, 'ASC_KEY_ID (leave empty to skip)', process.env.ASC_KEY_ID || '');
3952
- const issuerId = await ask(rl, 'ASC_ISSUER_ID (leave empty to skip)', process.env.ASC_ISSUER_ID || '');
3953
- if (keyId.trim())
3954
- secrets.ASC_KEY_ID = keyId.trim();
3970
+ process.stdout.write('\nNeeded values for the normal key now\n');
3971
+ printBullets([
3972
+ 'Issuer ID: shown at the top of the API keys page.',
3973
+ '.p8 file path: use Apple\'s original downloaded file name AuthKey_<KEY_ID>.p8.',
3974
+ 'Do not rename the .p8 file; the wizard reads ASC_KEY_ID from the file name.',
3975
+ 'Vendor Number: App Store Connect > Sales and Trends > Reports.',
3976
+ ]);
3977
+ const normalKeyPath = await askAscPrivateKeyPathWithKeyId(rl, {
3978
+ label: 'ASC_PRIVATE_KEY_PATH for normal reporting key (AuthKey_<KEY_ID>.p8 path, empty = paste content instead)',
3979
+ defaultValue: process.env.ASC_PRIVATE_KEY_PATH || '',
3980
+ keyLabel: 'the normal reporting key',
3981
+ });
3982
+ let keyId = normalKeyPath.keyId;
3983
+ if (normalKeyPath.privateKeyPath) {
3984
+ secrets.ASC_PRIVATE_KEY_PATH = normalKeyPath.privateKeyPath;
3985
+ secrets.ASC_KEY_ID = keyId;
3986
+ process.stdout.write(`Inferred ASC_KEY_ID=${keyId} from ${path.basename(normalKeyPath.privateKeyPath)}.\n`);
3987
+ }
3988
+ const issuerId = await ask(rl, 'ASC_ISSUER_ID for normal reporting key (leave empty to skip)', process.env.ASC_ISSUER_ID || '');
3955
3989
  if (issuerId.trim())
3956
3990
  secrets.ASC_ISSUER_ID = issuerId.trim();
3957
- const privateKeyContent = await askAscPrivateKeyContent(rl);
3958
- if (privateKeyContent) {
3991
+ if (!normalKeyPath.privateKeyPath) {
3992
+ keyId = await ask(rl, 'ASC_KEY_ID for normal reporting key (from AuthKey_<KEY_ID>.p8, leave empty to skip)', process.env.ASC_KEY_ID || '');
3993
+ if (keyId.trim())
3994
+ secrets.ASC_KEY_ID = keyId.trim();
3995
+ const privateKeyContent = await askAscPrivateKeyContent(rl, {
3996
+ keyLabel: 'the normal reporting key',
3997
+ });
3998
+ if (!privateKeyContent)
3999
+ return await guideAscBootstrapAdminKey(rl, issuerId.trim());
3959
4000
  const privateKeyPath = resolveAscPrivateKeyPath(keyId);
3960
4001
  await fs.mkdir(path.dirname(privateKeyPath), { recursive: true, mode: 0o700 });
3961
4002
  await fs.writeFile(privateKeyPath, privateKeyContent, { encoding: 'utf8', mode: 0o600 });
@@ -3963,14 +4004,85 @@ async function guideAscConnector(rl, secrets) {
3963
4004
  secrets.ASC_PRIVATE_KEY_PATH = privateKeyPath;
3964
4005
  process.stdout.write(`Saved ASC private key to ${privateKeyPath} with chmod 600.\n`);
3965
4006
  }
3966
- else {
3967
- const privateKeyPath = await askAscPrivateKeyPath(rl);
3968
- if (privateKeyPath.trim())
3969
- secrets.ASC_PRIVATE_KEY_PATH = privateKeyPath.trim();
3970
- }
3971
4007
  const vendorNumber = await ask(rl, 'ASC_VENDOR_NUMBER for Sales and Trends/App Units (required for healthy ASC status)', process.env.ASC_VENDOR_NUMBER || '');
3972
4008
  if (vendorNumber.trim())
3973
4009
  secrets.ASC_VENDOR_NUMBER = vendorNumber.trim();
4010
+ return await guideAscBootstrapAdminKey(rl, issuerId.trim());
4011
+ }
4012
+ async function guideAscBootstrapAdminKey(rl, issuerIdDefault = '') {
4013
+ const bootstrapEnv = {};
4014
+ process.stdout.write('\nStep 3 - temporary Admin key for first ASC setup\n');
4015
+ printBullets([
4016
+ 'Use the second key you created with the Admin role.',
4017
+ 'This is only needed to create the first App Analytics report request.',
4018
+ 'The wizard does not save ASC_BOOTSTRAP_* to secrets.env.',
4019
+ 'Use Apple\'s original AuthKey_<KEY_ID>.p8 file name so the wizard can infer ASC_BOOTSTRAP_KEY_ID.',
4020
+ 'Do not rename the .p8 file.',
4021
+ 'If you paste the .p8 content, the local temporary file is deleted after analytics initialization.',
4022
+ 'Revoke this Admin key in App Store Connect after setup.',
4023
+ ]);
4024
+ const bootstrapKeyPath = await askAscPrivateKeyPathWithKeyId(rl, {
4025
+ label: 'ASC_BOOTSTRAP_PRIVATE_KEY_PATH for temporary Admin key (AuthKey_<KEY_ID>.p8 path, empty = paste content instead)',
4026
+ defaultValue: '',
4027
+ keyLabel: 'the temporary Admin key',
4028
+ });
4029
+ let bootstrapKeyId = bootstrapKeyPath.keyId;
4030
+ const bootstrapIssuerId = await ask(rl, 'ASC_BOOTSTRAP_ISSUER_ID for temporary Admin key', issuerIdDefault);
4031
+ if (bootstrapKeyPath.privateKeyPath) {
4032
+ bootstrapEnv.ASC_BOOTSTRAP_PRIVATE_KEY_PATH = bootstrapKeyPath.privateKeyPath;
4033
+ process.stdout.write(`Inferred ASC_BOOTSTRAP_KEY_ID=${bootstrapKeyId} from ${path.basename(bootstrapKeyPath.privateKeyPath)}.\n`);
4034
+ const shouldDelete = await askYesNo(rl, 'Delete this temporary Admin .p8 file from this host after the setup check?', true);
4035
+ if (shouldDelete)
4036
+ bootstrapEnv.ASC_BOOTSTRAP_PRIVATE_KEY_DELETE_AFTER_USE = '1';
4037
+ }
4038
+ else {
4039
+ bootstrapKeyId = await ask(rl, 'ASC_BOOTSTRAP_KEY_ID for temporary Admin key (from AuthKey_<KEY_ID>.p8, required)', '');
4040
+ }
4041
+ if (!bootstrapKeyId.trim() || !bootstrapIssuerId.trim())
4042
+ return { bootstrapEnv };
4043
+ bootstrapEnv.ASC_BOOTSTRAP_KEY_ID = bootstrapKeyId.trim();
4044
+ bootstrapEnv.ASC_BOOTSTRAP_ISSUER_ID = bootstrapIssuerId.trim();
4045
+ if (!bootstrapKeyPath.privateKeyPath) {
4046
+ const bootstrapPrivateKeyContent = await askAscPrivateKeyContent(rl, {
4047
+ envName: 'ASC_BOOTSTRAP_PRIVATE_KEY',
4048
+ persistLabel: 'ASC_BOOTSTRAP_PRIVATE_KEY_PATH temporarily',
4049
+ keyLabel: 'the temporary Admin key',
4050
+ });
4051
+ if (!bootstrapPrivateKeyContent)
4052
+ return { bootstrapEnv };
4053
+ const bootstrapPrivateKeyPath = resolveAscPrivateKeyPath(bootstrapKeyId, '_bootstrap_admin');
4054
+ await fs.mkdir(path.dirname(bootstrapPrivateKeyPath), { recursive: true, mode: 0o700 });
4055
+ await fs.writeFile(bootstrapPrivateKeyPath, bootstrapPrivateKeyContent, { encoding: 'utf8', mode: 0o600 });
4056
+ await fs.chmod(bootstrapPrivateKeyPath, 0o600);
4057
+ bootstrapEnv.ASC_BOOTSTRAP_PRIVATE_KEY_PATH = bootstrapPrivateKeyPath;
4058
+ bootstrapEnv.ASC_BOOTSTRAP_PRIVATE_KEY_DELETE_AFTER_USE = '1';
4059
+ process.stdout.write(`Saved temporary Admin ASC private key to ${bootstrapPrivateKeyPath} with chmod 600. It will be deleted after the setup check.\n`);
4060
+ }
4061
+ return { bootstrapEnv };
4062
+ }
4063
+ async function cleanupTemporaryAscBootstrapPrivateKey(bootstrapEnv = {}) {
4064
+ const privateKeyPath = String(bootstrapEnv.ASC_BOOTSTRAP_PRIVATE_KEY_PATH || '').trim();
4065
+ const shouldDelete = String(bootstrapEnv.ASC_BOOTSTRAP_PRIVATE_KEY_DELETE_AFTER_USE || '').trim().toLowerCase();
4066
+ if (!privateKeyPath || !['1', 'true', 'yes'].includes(shouldDelete))
4067
+ return;
4068
+ if (privateKeyPath === String(bootstrapEnv.ASC_PRIVATE_KEY_PATH || process.env.ASC_PRIVATE_KEY_PATH || '').trim()) {
4069
+ process.stdout.write('Temporary Admin .p8 path matches the steady-state ASC_PRIVATE_KEY_PATH; leaving it in place.\n');
4070
+ process.stdout.write('You can also revoke the temporary Admin API key in App Store Connect.\n');
4071
+ return;
4072
+ }
4073
+ try {
4074
+ await fs.unlink(privateKeyPath);
4075
+ process.stdout.write(`Deleted temporary Admin .p8 from ${privateKeyPath}.\n`);
4076
+ }
4077
+ catch (error) {
4078
+ if (error?.code === 'ENOENT') {
4079
+ process.stdout.write(`Temporary Admin .p8 was already absent at ${privateKeyPath}.\n`);
4080
+ process.stdout.write('You can also revoke the temporary Admin API key in App Store Connect.\n');
4081
+ return;
4082
+ }
4083
+ process.stdout.write(`Could not delete temporary Admin .p8 at ${privateKeyPath}: ${error instanceof Error ? error.message : String(error)}\n`);
4084
+ }
4085
+ process.stdout.write('You can also revoke the temporary Admin API key in App Store Connect.\n');
3974
4086
  }
3975
4087
  async function shouldRunSelfUpdate(workspaceRoot, force) {
3976
4088
  if (force)
@@ -4204,13 +4316,16 @@ async function runConnectorSetupSteps({ rl, args, selected, healthByConnector, a
4204
4316
  if (selected.includes('asc')) {
4205
4317
  while (true) {
4206
4318
  clearTerminal();
4207
- await guideAscConnector(rl, secrets);
4208
- const check = await runImmediateConnectorHealthCheck({
4319
+ const ascSetup = await guideAscConnector(rl, secrets);
4320
+ let bootstrapEnv = ascSetup?.bootstrapEnv || {};
4321
+ let check = await runImmediateConnectorHealthCheck({
4209
4322
  rl,
4210
4323
  configPath: args.config,
4211
4324
  connector: 'asc',
4212
4325
  secrets,
4326
+ runtimeEnv: bootstrapEnv,
4213
4327
  });
4328
+ await cleanupTemporaryAscBootstrapPrivateKey(bootstrapEnv);
4214
4329
  if (!check.retry)
4215
4330
  break;
4216
4331
  }