@analyticscli/growth-engineer 0.1.1-preview.3 → 0.1.1-preview.30

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.
@@ -16,6 +16,7 @@ const ENABLE_ISOLATED_SECRET_RUNNER_WIZARD = false;
16
16
  const DEFAULT_GROWTH_INTERVAL_MINUTES = 90;
17
17
  const DEFAULT_CONNECTOR_HEALTH_INTERVAL_MINUTES = 360;
18
18
  const DEFAULT_SCHEDULER_PROOF_PATH = 'data/openclaw-growth-engineer/runtime/scheduler-proof.jsonl';
19
+ const DELETE_SECRET = '__OPENCLAW_DELETE_SECRET__';
19
20
  const GROWTH_ENGINEER_PACKAGE_SPEC = process.env.OPENCLAW_GROWTH_ENGINEER_PACKAGE || '@analyticscli/growth-engineer@preview';
20
21
  const RUNTIME_DIR = path.dirname(fileURLToPath(import.meta.url));
21
22
  const ACCOUNT_SIGNAL_CONNECTOR_KEYS = [
@@ -84,7 +85,7 @@ const CONNECTOR_DEFINITIONS = [
84
85
  key: 'paddle',
85
86
  label: 'Paddle Billing metrics',
86
87
  summary: 'Read web checkout, revenue, MRR, refunds, chargebacks, and active subscriber metrics.',
87
- needs: 'A Paddle API key with metrics.read permission for the live account.',
88
+ needs: 'A scoped Paddle API key for the live account with metrics.read permission.',
88
89
  },
89
90
  {
90
91
  key: 'seo',
@@ -108,7 +109,7 @@ const CONNECTOR_DEFINITIONS = [
108
109
  key: 'asc',
109
110
  label: 'ASC / App Store Connect CLI',
110
111
  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.',
112
+ 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
113
  },
113
114
  {
114
115
  key: 'stripe',
@@ -906,7 +907,9 @@ function sourceCommandNeedsActiveConfig(sourceName, command) {
906
907
  const value = String(command || '').toLowerCase();
907
908
  return (normalized === 'sentry' ||
908
909
  normalized === 'glitchtip' ||
910
+ normalized === 'paddle' ||
909
911
  normalized === 'coolify' ||
912
+ value.includes('export-paddle-summary') ||
910
913
  value.includes('export-sentry-summary') ||
911
914
  value.includes('export-coolify-summary') ||
912
915
  value.includes('exporters coolify-summary'));
@@ -1646,9 +1649,88 @@ async function connectorKeysForHealthCheck(configPath) {
1646
1649
  configured.add(key);
1647
1650
  return orderConnectors([...configured]);
1648
1651
  }
1652
+ function connectorKeyFromRunnerHealthKey(key) {
1653
+ const normalized = String(key || '').trim();
1654
+ if (normalized === 'analyticscli')
1655
+ return 'analytics';
1656
+ if (normalized === 'appStoreConnect')
1657
+ return 'asc';
1658
+ const connector = normalizeConnectorProgressKey(normalized);
1659
+ return connector || null;
1660
+ }
1661
+ function activeIncidentStatusLabel(status) {
1662
+ const normalized = String(status || '').trim();
1663
+ return normalized || 'blocked';
1664
+ }
1665
+ async function readActiveConnectorIncidents(configPath) {
1666
+ const statePath = deriveStatePathFromConfigPath(configPath);
1667
+ const state = await readJsonIfPresent(statePath).catch(() => null);
1668
+ const healthState = state?.connectorHealth;
1669
+ if (!healthState ||
1670
+ healthState.lastStatusOk !== false ||
1671
+ !healthState.activeIncidentFingerprint) {
1672
+ return {};
1673
+ }
1674
+ const alertJsonPath = healthState.lastAlertJsonPath
1675
+ ? path.resolve(String(healthState.lastAlertJsonPath))
1676
+ : path.resolve(path.dirname(statePath), 'runtime/connector-health/latest.json');
1677
+ const alertJson = await readJsonIfPresent(alertJsonPath).catch(() => null);
1678
+ const unhealthyConnectors = Array.isArray(alertJson?.unhealthyConnectors)
1679
+ ? alertJson.unhealthyConnectors
1680
+ : [];
1681
+ const incidents = {};
1682
+ for (const entry of unhealthyConnectors) {
1683
+ const key = connectorKeyFromRunnerHealthKey(entry?.key);
1684
+ if (!key)
1685
+ continue;
1686
+ incidents[key] = {
1687
+ ...entry,
1688
+ status: activeIncidentStatusLabel(entry?.status),
1689
+ detail: String(entry?.detail || 'Runner still has an active connector-health incident').trim(),
1690
+ activeRunnerIncident: true,
1691
+ activeIncidentFingerprint: healthState.activeIncidentFingerprint,
1692
+ lastCheckedAt: healthState.lastCheckedAt || null,
1693
+ };
1694
+ }
1695
+ return incidents;
1696
+ }
1697
+ function mergeActiveConnectorIncidents(healthByConnector, activeIncidents) {
1698
+ if (!activeIncidents || Object.keys(activeIncidents).length === 0) {
1699
+ return healthByConnector;
1700
+ }
1701
+ return Object.fromEntries(CONNECTOR_KEYS.map((key) => {
1702
+ const liveHealth = getConnectorHealth(key, healthByConnector);
1703
+ const incident = activeIncidents[key];
1704
+ if (!incident)
1705
+ return [key, liveHealth];
1706
+ if (liveHealth.status === 'connected') {
1707
+ return [
1708
+ key,
1709
+ {
1710
+ ...liveHealth,
1711
+ status: 'partial',
1712
+ detail: `Live wizard check passed, but the runner still has an active ${incident.status} incident; run the connector health job once to record recovery.`,
1713
+ activeRunnerIncident: true,
1714
+ activeIncidentFingerprint: incident.activeIncidentFingerprint,
1715
+ lastCheckedAt: incident.lastCheckedAt,
1716
+ },
1717
+ ];
1718
+ }
1719
+ return [
1720
+ key,
1721
+ {
1722
+ ...liveHealth,
1723
+ ...incident,
1724
+ status: incident.status || liveHealth.status || 'blocked',
1725
+ detail: incident.detail || liveHealth.detail,
1726
+ },
1727
+ ];
1728
+ }));
1729
+ }
1649
1730
  async function getConnectorPickerHealth(configPath, onProgress = () => { }, onlyConnectors = []) {
1731
+ const activeIncidents = await readActiveConnectorIncidents(configPath);
1650
1732
  if (!(await fileExists(configPath))) {
1651
- return Object.fromEntries(CONNECTOR_KEYS.map((key) => [
1733
+ const fallbackHealth = Object.fromEntries(CONNECTOR_KEYS.map((key) => [
1652
1734
  key,
1653
1735
  {
1654
1736
  status: isConnectorLocallyConfigured(key) ? 'unknown' : 'not_connected',
@@ -1657,9 +1739,11 @@ async function getConnectorPickerHealth(configPath, onProgress = () => { }, only
1657
1739
  : '',
1658
1740
  },
1659
1741
  ]));
1742
+ return mergeActiveConnectorIncidents(fallbackHealth, activeIncidents);
1660
1743
  }
1661
1744
  if (onlyConnectors.length === 0) {
1662
- return Object.fromEntries(CONNECTOR_KEYS.map((key) => [key, getConnectorHealth(key, {})]));
1745
+ const fallbackHealth = Object.fromEntries(CONNECTOR_KEYS.map((key) => [key, getConnectorHealth(key, {})]));
1746
+ return mergeActiveConnectorIncidents(fallbackHealth, activeIncidents);
1663
1747
  }
1664
1748
  const onlyArg = ` --only-connectors ${quote(orderConnectors(onlyConnectors).join(','))}`;
1665
1749
  const result = await runCommandCaptureWithProgress(`${nodeRuntimeScriptCommand('openclaw-growth-status.mjs')} --config ${quote(configPath)} --json --progress-json${onlyArg}`, onProgress);
@@ -1675,7 +1759,8 @@ async function getConnectorPickerHealth(configPath, onProgress = () => { }, only
1675
1759
  coolify: connectors.coolify,
1676
1760
  asc: connectors.appStoreConnect,
1677
1761
  };
1678
- return Object.fromEntries(CONNECTOR_KEYS.map((key) => [key, getConnectorHealth(key, healthByConnector)]));
1762
+ const liveHealth = Object.fromEntries(CONNECTOR_KEYS.map((key) => [key, getConnectorHealth(key, healthByConnector)]));
1763
+ return mergeActiveConnectorIncidents(liveHealth, activeIncidents);
1679
1764
  }
1680
1765
  function renderConnectorPicker(cursorIndex, selected, required, healthByConnector = {}, warning = '', copy = {}) {
1681
1766
  process.stdout.write('\x1b[2J\x1b[H');
@@ -1995,13 +2080,60 @@ function parseJsonFromStdout(stdout) {
1995
2080
  const starts = [firstBrace, firstBracket].filter((index) => index >= 0);
1996
2081
  if (starts.length === 0)
1997
2082
  return null;
2083
+ const start = Math.min(...starts);
2084
+ const jsonText = extractFirstJsonValue(raw, start);
2085
+ if (!jsonText)
2086
+ return null;
1998
2087
  try {
1999
- return JSON.parse(raw.slice(Math.min(...starts)));
2088
+ return JSON.parse(jsonText);
2000
2089
  }
2001
2090
  catch {
2002
2091
  return null;
2003
2092
  }
2004
2093
  }
2094
+ function extractFirstJsonValue(raw, start) {
2095
+ const open = raw[start];
2096
+ const close = open === '{' ? '}' : open === '[' ? ']' : '';
2097
+ if (!close)
2098
+ return '';
2099
+ let depth = 0;
2100
+ let inString = false;
2101
+ let escaped = false;
2102
+ for (let index = start; index < raw.length; index += 1) {
2103
+ const char = raw[index];
2104
+ if (inString) {
2105
+ if (escaped) {
2106
+ escaped = false;
2107
+ }
2108
+ else if (char === '\\') {
2109
+ escaped = true;
2110
+ }
2111
+ else if (char === '"') {
2112
+ inString = false;
2113
+ }
2114
+ continue;
2115
+ }
2116
+ if (char === '"') {
2117
+ inString = true;
2118
+ continue;
2119
+ }
2120
+ if (char === open)
2121
+ depth += 1;
2122
+ if (char === close) {
2123
+ depth -= 1;
2124
+ if (depth === 0)
2125
+ return raw.slice(start, index + 1);
2126
+ }
2127
+ }
2128
+ return '';
2129
+ }
2130
+ function stripProgressOutput(value) {
2131
+ return String(value || '')
2132
+ .split(/\r?\n/)
2133
+ .filter((line) => !line.startsWith('OPENCLAW_PROGRESS '))
2134
+ .join('\n')
2135
+ .trim();
2136
+ }
2005
2137
  function clearTerminal() {
2006
2138
  if (process.stdout.isTTY) {
2007
2139
  process.stdout.write('\x1b[2J\x1b[H');
@@ -2042,6 +2174,21 @@ function getPassingConnectorKeys(payload, failedConnectors = new Set()) {
2042
2174
  }
2043
2175
  function summarizeFailureReason(detail) {
2044
2176
  const text = String(detail || '').replace(/\s+/g, ' ').trim();
2177
+ if (/ASC Reports key auth failed: .*\.p8 key could not be parsed/i.test(text)) {
2178
+ return 'ASC Reports key auth failed: the .p8 key could not be parsed';
2179
+ }
2180
+ if (/ASC Setup Admin key auth failed: .*\.p8 key could not be parsed/i.test(text)) {
2181
+ return 'ASC Setup Admin key auth failed: the .p8 key could not be parsed';
2182
+ }
2183
+ if (/ASC Reports key auth failed: .*\.p8 file permissions are too open/i.test(text)) {
2184
+ return 'ASC Reports key auth failed: .p8 file permissions are too open';
2185
+ }
2186
+ if (/ASC Setup Admin key auth failed: .*\.p8 file permissions are too open/i.test(text)) {
2187
+ return 'ASC Setup Admin key auth failed: .p8 file permissions are too open';
2188
+ }
2189
+ if (/ASC .*\.p8 private key is invalid|invalid private key|failed to parse|sequence truncated|malformed|asn1/i.test(text)) {
2190
+ return 'ASC auth failed: the .p8 key could not be parsed';
2191
+ }
2045
2192
  if (/token has been revoked/i.test(text))
2046
2193
  return 'token has been revoked';
2047
2194
  if (/unauthorized|UNAUTHORIZED/i.test(text))
@@ -2075,7 +2222,7 @@ function summarizeFailureFix(connector, blockers) {
2075
2222
  return 'Paste a RevenueCat v2 secret API key with read-only project permissions, then rerun setup.';
2076
2223
  }
2077
2224
  if (connector === 'paddle') {
2078
- return 'Paste a Paddle API key with metrics.read permission for the live account, then rerun setup.';
2225
+ return 'Paste a live Paddle API key from Developer Tools > Authentication v2 with metrics.read permission, then rerun setup.';
2079
2226
  }
2080
2227
  if (connector === 'seo') {
2081
2228
  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.';
@@ -2084,6 +2231,21 @@ function summarizeFailureFix(connector, blockers) {
2084
2231
  return 'Paste a Coolify base URL and read-only API token from Keys & Tokens / API tokens, then rerun setup.';
2085
2232
  }
2086
2233
  if (connector === 'asc') {
2234
+ if (/invalid value: ['"]?state|parameter has an invalid value: ['"]?state|--state/i.test(combined)) {
2235
+ return 'Update Growth Engineer and rerun ASC setup. Setup no longer uses the flaky ASC analytics request state filter.';
2236
+ }
2237
+ if (/file permissions are too open|too permissive|chmod 600/i.test(combined)) {
2238
+ return 'Rerun ASC setup. The wizard saves a secure local copy of AuthKey_<KEY_ID>.p8 with chmod 600 before testing.';
2239
+ }
2240
+ if (/Reports key auth failed|Reports key/i.test(combined) && /private key|could not be parsed|failed to parse|asn1/i.test(combined)) {
2241
+ return 'Use the original downloaded AuthKey_<KEY_ID>.p8 file for the Reports key. The wizard bypasses old asc keychain/config credentials during setup.';
2242
+ }
2243
+ if (/Setup Admin key auth failed|Admin key/i.test(combined) && /private key|could not be parsed|failed to parse|asn1/i.test(combined)) {
2244
+ return 'Use the original downloaded AuthKey_<KEY_ID>.p8 file for the Setup Admin key. This key is temporary and should have the Admin role.';
2245
+ }
2246
+ if (/invalid|truncated|malformed|private key|could not be parsed|failed to parse|asn1/i.test(combined)) {
2247
+ return 'Use the original downloaded AuthKey_<KEY_ID>.p8 for the Reports key. Old pasted ASC_PRIVATE_KEY values are removed when you choose a file path.';
2248
+ }
2087
2249
  return 'Rerun ASC setup and verify ASC credentials, key role access, and `asc apps list --output json`.';
2088
2250
  }
2089
2251
  if (isAccountSignalConnector(connector)) {
@@ -2132,21 +2294,15 @@ function printConciseSetupBlockers(payload, command, options = {}) {
2132
2294
  process.stdout.write(`Live checks passed: ${passingConnectors.map(connectorTitle).join(', ')}.\n`);
2133
2295
  }
2134
2296
  if (groups.size > 0) {
2135
- process.stdout.write('\nNeeds fix:\n');
2297
+ process.stdout.write('\nNeeds attention:\n');
2136
2298
  for (const [connector, connectorBlockers] of groups.entries()) {
2137
2299
  const primary = connectorBlockers[0] || {};
2138
- const reasons = [
2139
- ...new Set(connectorBlockers
2140
- .map((blocker) => summarizeFailureReason(blocker.detail || blocker.check))
2141
- .filter(Boolean)),
2142
- ];
2143
2300
  process.stdout.write(`- ${connectorTitle(connector)}: ${summarizeFailureReason(primary.detail || primary.check)}\n`);
2144
- process.stdout.write(` Why: ${reasons.join('; ')}.\n`);
2145
2301
  process.stdout.write(` Fix: ${summarizeFailureFix(connector, connectorBlockers)}\n`);
2146
2302
  }
2147
2303
  }
2148
2304
  printDeferredSetupNotes(blockers, focusConnectors);
2149
- if (groups.size > 0 || !options.hideRerunWhenClean) {
2305
+ if (!options.hideRerun && (groups.size > 0 || !options.hideRerunWhenClean)) {
2150
2306
  process.stdout.write(`\nRerun: ${command}\n`);
2151
2307
  }
2152
2308
  }
@@ -2202,11 +2358,10 @@ function printSetupFailure({ result, payload, command }) {
2202
2358
  }
2203
2359
  const reason = result.code === null ? 'setup command did not report an exit code' : `setup command exited with code ${result.code}`;
2204
2360
  process.stdout.write(`Reason: ${reason}.\n`);
2205
- const output = truncate(result.stderr || result.stdout);
2361
+ const output = truncate(stripProgressOutput(result.stderr) || stripProgressOutput(result.stdout));
2206
2362
  if (output) {
2207
2363
  process.stdout.write(`Details: ${output}\n`);
2208
2364
  }
2209
- process.stdout.write(`Run manually for full output: ${command}\n`);
2210
2365
  }
2211
2366
  function printSetupSuccess(payload) {
2212
2367
  process.stdout.write('\nSUCCESS: Connector setup finished.\n');
@@ -2291,9 +2446,9 @@ function isDeferredGitHubFailure(failure) {
2291
2446
  return (name === 'project:github-repo' ||
2292
2447
  (name === 'connection:github' && /project\.githubRepo|repo is missing|repo is not configured/i.test(detail)));
2293
2448
  }
2294
- function healthStatusLabel(status) {
2449
+ function healthStatusLabel(status, spinner = '') {
2295
2450
  if (status === 'running')
2296
- return 'running';
2451
+ return spinner ? `running ${spinner}` : 'running';
2297
2452
  if (status === 'pass')
2298
2453
  return 'done';
2299
2454
  if (status === 'warn')
@@ -2302,18 +2457,27 @@ function healthStatusLabel(status) {
2302
2457
  return 'needs attention';
2303
2458
  if (status === 'deferred')
2304
2459
  return 'deferred';
2305
- return 'pending';
2460
+ return spinner ? `pending ${spinner}` : 'pending';
2306
2461
  }
2307
- function renderHealthProgress(items, message = 'Live checks running...', title = 'Health check') {
2462
+ function renderHealthProgress(items, message = 'Live checks running...', title = 'Health check', options = {}) {
2308
2463
  if (process.stdout.isTTY)
2309
2464
  clearTerminal();
2310
- const finished = items.filter((item) => !['pending', 'running'].includes(String(item.status || ''))).length;
2465
+ const final = Boolean(options.final);
2466
+ const visibleItems = final
2467
+ ? items.filter((item) => !['pending', 'running'].includes(String(item.status || '')) && item.key !== 'finalize')
2468
+ : items;
2469
+ const finished = visibleItems.filter((item) => !['pending', 'running'].includes(String(item.status || ''))).length;
2311
2470
  process.stdout.write(`${title}\n`);
2312
2471
  process.stdout.write('------------\n');
2313
2472
  process.stdout.write(`${message}\n\n`);
2314
- process.stdout.write(`${finished}/${items.length} checks finished.\n\n`);
2315
- for (const item of items) {
2316
- process.stdout.write(`[${healthStatusLabel(item.status)}] ${item.label}: ${item.detail}\n`);
2473
+ if (final) {
2474
+ process.stdout.write('');
2475
+ }
2476
+ else {
2477
+ process.stdout.write(`${finished}/${visibleItems.length} checks finished.\n\n`);
2478
+ }
2479
+ for (const item of visibleItems) {
2480
+ process.stdout.write(`[${healthStatusLabel(item.status, options.spinner || '')}] ${item.label}: ${item.detail}\n`);
2317
2481
  }
2318
2482
  }
2319
2483
  function updateHealthProgress(items, event) {
@@ -2424,22 +2588,41 @@ function updateProgressItem(items, key, status, detail) {
2424
2588
  }
2425
2589
  async function runSetupCommandWithProgress(command, env, selected, message) {
2426
2590
  const plan = buildSetupTestProgressPlan(selected);
2427
- renderHealthProgress(plan, `${message}\nDo not close this terminal yet.`, 'Connector setup test');
2591
+ const spinnerFrames = ['-', '\\', '|', '/'];
2592
+ let spinnerIndex = 0;
2593
+ let currentMessage = message;
2594
+ const render = (nextMessage = currentMessage, options = {}) => {
2595
+ currentMessage = nextMessage;
2596
+ renderHealthProgress(plan, currentMessage, 'Connector setup test', {
2597
+ ...options,
2598
+ spinner: spinnerFrames[spinnerIndex++ % spinnerFrames.length],
2599
+ });
2600
+ };
2601
+ render(message);
2602
+ const spinnerInterval = process.stdout.isTTY
2603
+ ? setInterval(() => render(currentMessage), 800)
2604
+ : null;
2428
2605
  const progressCommand = command.includes('--progress-json') ? command : `${command} --progress-json`;
2429
2606
  const result = await runCommandCaptureWithProgress(progressCommand, (event) => {
2430
- if (updateHealthProgress(plan, event)) {
2431
- const primaryFinished = primaryProgressItemsFinished(plan);
2432
- if (primaryFinished) {
2433
- updateProgressItem(plan, 'finalize', 'running', 'command still running; parsing final output and follow-up work');
2434
- }
2435
- const message = primaryFinished
2436
- ? 'Checks finished. Finalizing result; do not close this terminal yet.'
2437
- : 'Connector setup test is still running. Do not close this terminal yet.';
2438
- renderHealthProgress(plan, message, 'Connector setup test');
2607
+ if (!updateHealthProgress(plan, event))
2608
+ return;
2609
+ const primaryFinished = primaryProgressItemsFinished(plan);
2610
+ if (primaryFinished) {
2611
+ updateProgressItem(plan, 'finalize', 'running', 'finishing');
2439
2612
  }
2440
- }, { env, timeoutMs: 180_000 });
2613
+ render(primaryFinished ? 'Finishing setup test...' : 'Testing connector setup...');
2614
+ }, { env, timeoutMs: 180_000 }).finally(() => {
2615
+ if (spinnerInterval)
2616
+ clearInterval(spinnerInterval);
2617
+ });
2618
+ const payload = parseJsonFromStdout(result.stdout);
2619
+ if (Array.isArray(payload?.blockers) && payload.blockers.length > 0) {
2620
+ if (process.stdout.isTTY)
2621
+ clearTerminal();
2622
+ return result;
2623
+ }
2441
2624
  updateProgressItem(plan, 'finalize', 'pass', 'result received');
2442
- renderHealthProgress(plan, 'Connector setup test finished.', 'Connector setup test');
2625
+ renderHealthProgress(plan, 'Connector setup test finished.', 'Connector setup test', { final: true });
2443
2626
  return result;
2444
2627
  }
2445
2628
  async function saveSecretsImmediately(secrets) {
@@ -2447,19 +2630,19 @@ async function saveSecretsImmediately(secrets) {
2447
2630
  return false;
2448
2631
  const secretsFile = resolveSecretsFile();
2449
2632
  await writeSecretsFile(secretsFile, secrets);
2450
- Object.assign(process.env, secrets);
2633
+ applySecretsToProcessEnv(secrets);
2451
2634
  process.stdout.write(`Saved local secrets to ${secretsFile} with chmod 600.\n`);
2452
2635
  return true;
2453
2636
  }
2454
- async function runImmediateConnectorHealthCheck({ rl, configPath, connector, secrets, sentryAccounts = [], }) {
2637
+ async function runImmediateConnectorHealthCheck({ rl, configPath, connector, secrets, runtimeEnv = {}, sentryAccounts = [], paddleAccounts = [], }) {
2455
2638
  if (connector === 'sentry' && sentryAccounts.length > 0) {
2456
2639
  await upsertSentryAccountsConfig(configPath, sentryAccounts);
2457
2640
  }
2641
+ if (connector === 'paddle' && paddleAccounts.length > 0) {
2642
+ await upsertPaddleAccountsConfig(configPath, paddleAccounts);
2643
+ }
2458
2644
  await saveSecretsImmediately(secrets);
2459
- const env = {
2460
- ...process.env,
2461
- ...secrets,
2462
- };
2645
+ const env = mergeRuntimeEnv(secrets, connector === 'asc' ? { ASC_BYPASS_KEYCHAIN: '1' } : {}, runtimeEnv);
2463
2646
  const command = `${nodeRuntimeScriptCommand('openclaw-growth-start.mjs')} --config ${quote(configPath)} --setup-only --connectors ${quote(connector)} --only-connectors ${quote(connector)}`;
2464
2647
  let result = await runSetupCommandWithProgress(command, env, [connector], `Checking ${connectorLabel(connector)} immediately after setup...`);
2465
2648
  let payload = parseJsonFromStdout(result.stdout);
@@ -2468,6 +2651,7 @@ async function runImmediateConnectorHealthCheck({ rl, configPath, connector, sec
2468
2651
  printConciseSetupBlockers(payload, command, {
2469
2652
  focusConnectors: [connector],
2470
2653
  hideRerunWhenClean: true,
2654
+ hideRerun: true,
2471
2655
  });
2472
2656
  const retry = await askYesNo(rl, `Re-enter ${connectorLabel(connector)} configuration now?`, true);
2473
2657
  return { ok: false, retry, result, payload };
@@ -2599,12 +2783,26 @@ function resolveSecretsFile() {
2599
2783
  return path.join(process.env.HOME, '.config', 'openclaw-growth', 'secrets.env');
2600
2784
  return path.resolve('.openclaw-growth-secrets.env');
2601
2785
  }
2602
- function resolveAscPrivateKeyPath(keyId) {
2786
+ function resolveAscPrivateKeyPath(keyId, suffix = '') {
2603
2787
  const safeKeyId = (keyId || 'OPENCLAW').trim().replace(/[^a-zA-Z0-9_-]/g, '_') || 'OPENCLAW';
2604
2788
  const baseDir = process.env.HOME
2605
2789
  ? path.join(process.env.HOME, '.config', 'openclaw-growth')
2606
2790
  : path.resolve('.openclaw-growth');
2607
- return path.join(baseDir, `AuthKey_${safeKeyId}.p8`);
2791
+ return path.join(baseDir, `AuthKey_${safeKeyId}${suffix}.p8`);
2792
+ }
2793
+ function inferAscKeyIdFromPrivateKeyPath(filePath) {
2794
+ const fileName = path.basename(String(filePath || '').trim());
2795
+ const match = fileName.match(/^AuthKey_([A-Za-z0-9]+)\.p8$/);
2796
+ return match?.[1] || '';
2797
+ }
2798
+ async function copyAscPrivateKeyToSecurePath(sourcePath, keyId, suffix = '') {
2799
+ const destinationPath = resolveAscPrivateKeyPath(keyId, suffix);
2800
+ await fs.mkdir(path.dirname(destinationPath), { recursive: true, mode: 0o700 });
2801
+ if (path.resolve(sourcePath) !== path.resolve(destinationPath)) {
2802
+ await fs.copyFile(sourcePath, destinationPath);
2803
+ }
2804
+ await fs.chmod(destinationPath, 0o600);
2805
+ return destinationPath;
2608
2806
  }
2609
2807
  function renderEnvValue(value) {
2610
2808
  return `"${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$')}"`;
@@ -2629,6 +2827,10 @@ async function readSecretsFile(filePath) {
2629
2827
  async function writeSecretsFile(filePath, nextValues) {
2630
2828
  const current = await readSecretsFile(filePath);
2631
2829
  for (const [key, value] of Object.entries(nextValues)) {
2830
+ if (value === DELETE_SECRET) {
2831
+ current.delete(key);
2832
+ continue;
2833
+ }
2632
2834
  if (value.trim())
2633
2835
  current.set(key, value.trim());
2634
2836
  }
@@ -2642,6 +2844,30 @@ async function writeSecretsFile(filePath, nextValues) {
2642
2844
  await fs.writeFile(filePath, lines.join('\n'), { encoding: 'utf8', mode: 0o600 });
2643
2845
  await fs.chmod(filePath, 0o600);
2644
2846
  }
2847
+ function applySecretsToProcessEnv(nextValues) {
2848
+ for (const [key, value] of Object.entries(nextValues)) {
2849
+ if (value === DELETE_SECRET) {
2850
+ delete process.env[key];
2851
+ continue;
2852
+ }
2853
+ if (value.trim())
2854
+ process.env[key] = value.trim();
2855
+ }
2856
+ }
2857
+ function mergeRuntimeEnv(...sources) {
2858
+ const env = { ...process.env };
2859
+ for (const source of sources) {
2860
+ for (const [key, value] of Object.entries(source || {})) {
2861
+ if (value === DELETE_SECRET) {
2862
+ delete env[key];
2863
+ continue;
2864
+ }
2865
+ if (String(value || '').trim())
2866
+ env[key] = String(value).trim();
2867
+ }
2868
+ }
2869
+ return env;
2870
+ }
2645
2871
  function renderBashSingleQuoted(value) {
2646
2872
  return `'${String(value).replace(/'/g, `'\\''`)}'`;
2647
2873
  }
@@ -3133,6 +3359,71 @@ async function verifySentryAccountsConfig(configPath, expectedAccounts) {
3133
3359
  }
3134
3360
  return { ok: true, detail: `${realAccounts.length} active Sentry-compatible account(s) configured` };
3135
3361
  }
3362
+ async function upsertPaddleAccountsConfig(configPath, accounts) {
3363
+ if (!accounts.length || !(await fileExists(configPath)))
3364
+ return false;
3365
+ const config = await readJsonFile(configPath);
3366
+ const existingAccounts = Array.isArray(config?.sources?.paddle?.accounts)
3367
+ ? config.sources.paddle.accounts
3368
+ : [];
3369
+ const merged = new Map();
3370
+ for (const account of existingAccounts) {
3371
+ const id = String(account?.id || account?.key || account?.label || '').trim();
3372
+ if (id)
3373
+ merged.set(id, account);
3374
+ }
3375
+ for (const account of accounts) {
3376
+ merged.set(account.id, {
3377
+ ...(merged.get(account.id) || {}),
3378
+ ...account,
3379
+ });
3380
+ }
3381
+ const tokenEnv = accounts[0]?.tokenEnv || config?.sources?.paddle?.tokenEnv || config?.secrets?.paddleTokenEnv || 'PADDLE_API_KEY';
3382
+ config.sources = {
3383
+ ...(config.sources || {}),
3384
+ paddle: {
3385
+ ...(config.sources?.paddle || {}),
3386
+ enabled: true,
3387
+ mode: 'command',
3388
+ command: normalizeWizardSourceCommand('paddle', config.sources?.paddle || {}, configPath),
3389
+ environment: config.sources?.paddle?.environment || 'live',
3390
+ tokenEnv,
3391
+ accounts: [...merged.values()],
3392
+ },
3393
+ };
3394
+ config.secrets = {
3395
+ ...(config.secrets || {}),
3396
+ paddleTokenEnv: tokenEnv,
3397
+ paddleTokenRef: { source: 'env', provider: 'default', id: tokenEnv },
3398
+ };
3399
+ await writeJsonFile(configPath, config);
3400
+ return true;
3401
+ }
3402
+ async function verifyPaddleAccountsConfig(configPath, expectedAccounts) {
3403
+ if (!(await fileExists(configPath))) {
3404
+ return { ok: false, detail: `${configPath} does not exist` };
3405
+ }
3406
+ const config = await readJsonFile(configPath);
3407
+ const source = config?.sources?.paddle;
3408
+ if (!source || source.enabled !== true) {
3409
+ return { ok: false, detail: 'sources.paddle.enabled is not true' };
3410
+ }
3411
+ if (source.mode !== 'command') {
3412
+ return { ok: false, detail: 'sources.paddle.mode is not command' };
3413
+ }
3414
+ const configuredAccounts = Array.isArray(source.accounts) ? source.accounts : [];
3415
+ if (configuredAccounts.length === 0) {
3416
+ return { ok: false, detail: 'sources.paddle.accounts contains no account' };
3417
+ }
3418
+ const configuredIds = new Set(configuredAccounts.map((account) => String(account?.id || account?.key || '').trim()).filter(Boolean));
3419
+ const missingIds = expectedAccounts
3420
+ .map((account) => String(account?.id || '').trim())
3421
+ .filter((id) => id && !configuredIds.has(id));
3422
+ if (missingIds.length > 0) {
3423
+ return { ok: false, detail: `sources.paddle.accounts is missing configured account id(s): ${missingIds.join(', ')}` };
3424
+ }
3425
+ return { ok: true, detail: `${configuredAccounts.length} Paddle account(s) configured` };
3426
+ }
3136
3427
  async function upsertCoolifyConfig(configPath, { baseUrl, tokenEnv = 'COOLIFY_API_TOKEN' }) {
3137
3428
  if (!(await fileExists(configPath)))
3138
3429
  return false;
@@ -3232,11 +3523,14 @@ function validateAscPrivateKeyContent(value) {
3232
3523
  };
3233
3524
  }
3234
3525
  }
3235
- async function askAscPrivateKeyContent(rl) {
3236
- 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');
3237
- process.stdout.write('The wizard validates the pasted key, stores it locally with chmod 600, and only saves ASC_PRIVATE_KEY_PATH.\n');
3526
+ async function askAscPrivateKeyContent(rl, options = {}) {
3527
+ const envName = options.envName || 'ASC_PRIVATE_KEY';
3528
+ const persistLabel = options.persistLabel || 'ASC_PRIVATE_KEY_PATH';
3529
+ const keyLabel = options.keyLabel || 'this App Store Connect key';
3530
+ 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`);
3531
+ process.stdout.write(`The wizard validates the pasted key, stores it locally with chmod 600, and only saves ${persistLabel}.\n`);
3238
3532
  while (true) {
3239
- const value = await readAscPrivateKeyPaste(rl);
3533
+ const value = await readAscPrivateKeyPaste(rl, envName);
3240
3534
  if (!value.trim())
3241
3535
  return '';
3242
3536
  const validation = validateAscPrivateKeyContent(value);
@@ -3246,7 +3540,7 @@ async function askAscPrivateKeyContent(rl) {
3246
3540
  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');
3247
3541
  }
3248
3542
  }
3249
- async function readAscPrivateKeyPaste(rl) {
3543
+ async function readAscPrivateKeyPaste(rl, envName = 'ASC_PRIVATE_KEY') {
3250
3544
  return await new Promise((resolve, reject) => {
3251
3545
  let buffer = '';
3252
3546
  let settled = false;
@@ -3307,7 +3601,7 @@ async function readAscPrivateKeyPaste(rl) {
3307
3601
  process.stdin.setEncoding('utf8');
3308
3602
  process.stdin.on('data', onData);
3309
3603
  process.stdin.on('error', onError);
3310
- process.stdout.write('ASC_PRIVATE_KEY content: ');
3604
+ process.stdout.write(`${envName} content: `);
3311
3605
  process.stdin.resume();
3312
3606
  });
3313
3607
  }
@@ -3315,9 +3609,11 @@ async function validateAscPrivateKeyPath(filePath) {
3315
3609
  const raw = await fs.readFile(filePath, 'utf8');
3316
3610
  return validateAscPrivateKeyContent(raw);
3317
3611
  }
3318
- async function askAscPrivateKeyPath(rl) {
3612
+ async function askAscPrivateKeyPath(rl, options = {}) {
3613
+ const label = options.label || 'ASC_PRIVATE_KEY_PATH (path to AuthKey_XXXX.p8, leave empty to skip)';
3614
+ const defaultValue = options.defaultValue ?? process.env.ASC_PRIVATE_KEY_PATH ?? '';
3319
3615
  while (true) {
3320
- const privateKeyPath = await ask(rl, 'ASC_PRIVATE_KEY_PATH (path to AuthKey_XXXX.p8, leave empty to skip)', process.env.ASC_PRIVATE_KEY_PATH || '');
3616
+ const privateKeyPath = await ask(rl, label, defaultValue);
3321
3617
  const trimmedPath = privateKeyPath.trim();
3322
3618
  if (!trimmedPath)
3323
3619
  return '';
@@ -3333,6 +3629,19 @@ async function askAscPrivateKeyPath(rl) {
3333
3629
  process.stdout.write('The ASC private key path was not saved. Paste a valid path, or leave empty to skip.\n');
3334
3630
  }
3335
3631
  }
3632
+ async function askAscPrivateKeyPathWithKeyId(rl, options = {}) {
3633
+ const keyLabel = options.keyLabel || 'App Store Connect key';
3634
+ while (true) {
3635
+ const privateKeyPath = await askAscPrivateKeyPath(rl, options);
3636
+ if (!privateKeyPath)
3637
+ return { privateKeyPath: '', keyId: '' };
3638
+ const keyId = inferAscKeyIdFromPrivateKeyPath(privateKeyPath);
3639
+ if (keyId)
3640
+ return { privateKeyPath, keyId };
3641
+ process.stdout.write(`Could not infer Key ID for ${keyLabel} from the .p8 file name.\n`);
3642
+ process.stdout.write('Use Apple\'s original downloaded file name: AuthKey_<KEY_ID>.p8. Do not rename the .p8 file.\n');
3643
+ }
3644
+ }
3336
3645
  function printSection(title, lines = []) {
3337
3646
  process.stdout.write(`\n${ANSI.bold}${title}${ANSI.reset}\n`);
3338
3647
  process.stdout.write(`${'-'.repeat(title.length)}\n`);
@@ -3348,13 +3657,14 @@ function printBullets(lines) {
3348
3657
  }
3349
3658
  process.stdout.write('\n');
3350
3659
  }
3660
+ function bold(text) {
3661
+ return `${ANSI.bold}${text}${ANSI.reset}`;
3662
+ }
3351
3663
  async function guideGitHubConnector(rl, secrets) {
3352
3664
  printSection('GitHub code access', [
3353
- 'Use this when OpenClaw should read repo context or create GitHub delivery artifacts.',
3354
- ]);
3355
- printBullets([
3356
- 'Open the token page, select the scopes you want, then paste the token here.',
3357
- 'You can rerun this wizard later to change GitHub permissions.',
3665
+ `${bold('Create token')} here: https://github.com/settings/tokens/new`,
3666
+ `${bold('Scopes')}: public repos = public_repo, private repos/issues/PRs = repo.`,
3667
+ `${bold('Only add workflow')} if OpenClaw should edit GitHub Actions files.`,
3358
3668
  ]);
3359
3669
  let hasGh = await commandExists('gh');
3360
3670
  if (!hasGh) {
@@ -3363,15 +3673,6 @@ async function guideGitHubConnector(rl, secrets) {
3363
3673
  if (hasGh) {
3364
3674
  process.stdout.write('GitHub CLI is available for helper commands.\n\n');
3365
3675
  }
3366
- process.stdout.write('Token URL: https://github.com/settings/tokens/new\n\n');
3367
- process.stdout.write(`${ANSI.bold}Suggested scopes${ANSI.reset}\n`);
3368
- printBullets([
3369
- 'Public repo only: select `public_repo`.',
3370
- 'Private repo access: select `repo` (classic GitHub tokens make private repo access broad).',
3371
- 'Create issues / draft PRs in private repos: `repo` is the relevant classic-token scope.',
3372
- 'Edit GitHub Actions workflow files: add `workflow` only if you explicitly want this.',
3373
- 'Usually do not select: packages, admin:org, hooks, gist, user, delete_repo, enterprise, codespace, copilot.',
3374
- ]);
3375
3676
  const token = await maybePromptSecret(rl, 'Paste GITHUB_TOKEN into this local terminal', 'GITHUB_TOKEN');
3376
3677
  if (token)
3377
3678
  secrets.GITHUB_TOKEN = token;
@@ -3392,12 +3693,10 @@ function shouldForceFreshAnalyticsToken(healthByConnector = {}) {
3392
3693
  return ['blocked', 'partial'].includes(String(health?.status || '')) || /revoked|unauthorized|invalid token/i.test(detail);
3393
3694
  }
3394
3695
  async function guideAnalyticsConnector(rl, secrets, options = {}) {
3395
- printSection('AnalyticsCLI');
3396
- process.stdout.write('Create a readonly CLI token:\n');
3397
- process.stdout.write('1. Open https://dash.analyticscli.com/\n');
3398
- process.stdout.write('2. Account -> API Keys\n');
3399
- process.stdout.write('3. Create Access Token\n');
3400
- process.stdout.write('4. Copy the Readonly CLI Token and paste it below\n\n');
3696
+ printSection('AnalyticsCLI', [
3697
+ `${bold('Create readonly CLI token')}: https://dash.analyticscli.com/`,
3698
+ `${bold('Path')}: Account -> API Keys -> Create Access Token.`,
3699
+ ]);
3401
3700
  const forceFresh = Boolean(options.forceFresh);
3402
3701
  if (forceFresh && process.env.ANALYTICSCLI_ACCESS_TOKEN) {
3403
3702
  process.stdout.write('Stored token failed. Paste a new token.\n\n');
@@ -3414,51 +3713,64 @@ async function guideAnalyticsConnector(rl, secrets, options = {}) {
3414
3713
  }
3415
3714
  async function guideRevenueCatConnector(rl, secrets) {
3416
3715
  printSection('RevenueCat monetization data', [
3417
- 'Use this when OpenClaw should read subscription, product, entitlement, and revenue context.',
3418
- ]);
3419
- process.stdout.write('\nCreate a RevenueCat secret API key here:\n https://app.revenuecat.com/\n\n');
3420
- printBullets([
3421
- 'Select your app.',
3422
- 'In the sidebar, choose "Apps & providers".',
3423
- 'Click "API keys" and generate a new secret API key.',
3424
- 'Name it "analyticscli" and choose API version 2.',
3425
- 'Set Charts metrics permissions to read.',
3426
- 'Set Customer information permissions to read.',
3427
- 'Set Project configuration permissions to read.',
3716
+ `${bold('Create secret API key')}: https://app.revenuecat.com/`,
3717
+ `${bold('Path')}: Apps & providers -> API keys -> New secret key.`,
3718
+ `${bold('Permissions')}: API v2, read for Charts metrics, Customer information, Project configuration.`,
3428
3719
  ]);
3429
3720
  const apiKey = await maybePromptSecret(rl, 'Paste REVENUECAT_API_KEY into this local terminal', 'REVENUECAT_API_KEY');
3430
3721
  if (apiKey)
3431
3722
  secrets.REVENUECAT_API_KEY = apiKey;
3432
3723
  }
3724
+ function paddleAccountIdFromLabel(label, index) {
3725
+ const normalized = String(label || '')
3726
+ .trim()
3727
+ .toLowerCase()
3728
+ .replace(/[^a-z0-9]+/g, '_')
3729
+ .replace(/^_+|_+$/g, '');
3730
+ return normalized || `paddle_${index + 1}`;
3731
+ }
3732
+ function paddleTokenEnvForAccount(index, label) {
3733
+ if (index === 0)
3734
+ return 'PADDLE_API_KEY';
3735
+ const suffix = paddleAccountIdFromLabel(label, index).toUpperCase().replace(/[^A-Z0-9]+/g, '_');
3736
+ const base = suffix && suffix !== `PADDLE_${index + 1}` ? `PADDLE_API_KEY_${suffix}` : `PADDLE_API_KEY_${index + 1}`;
3737
+ return base.replace(/_+/g, '_');
3738
+ }
3433
3739
  async function guidePaddleConnector(rl, secrets) {
3434
3740
  printSection('Paddle Billing metrics', [
3435
- 'Use this when OpenClaw should read web checkout, revenue, MRR, refunds, chargebacks, and active subscriber metrics.',
3741
+ `${bold('Create live API key')}: https://vendors.paddle.com/authentication-v2`,
3742
+ `${bold('Minimum')}: metrics.read. Better: all read-only *.read scopes.`,
3743
+ `${bold('Do not grant write scopes')} unless you explicitly need them elsewhere.`,
3436
3744
  ]);
3437
- process.stdout.write('\nCreate or update a Paddle API key here:\n https://vendors.paddle.com/authentication\n\n');
3438
- printBullets([
3439
- 'Open Paddle > Developer Tools > Authentication.',
3440
- 'Create a new API key for the live account when you want production revenue evidence.',
3441
- 'Grant `metrics.read`. Keep write permissions off unless another workflow explicitly needs them.',
3442
- 'Do not select or hard-code a single product in the wizard; the Growth Engineer should keep account-level metrics context.',
3443
- 'Paste the key here so it is stored only in the local chmod 600 secrets file.',
3444
- ]);
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;
3745
+ const accounts = [];
3746
+ let index = 0;
3747
+ while (true) {
3748
+ const label = await ask(rl, index === 0 ? 'Paddle account label' : 'Next Paddle account label (empty = done)', index === 0 ? 'Paddle' : '');
3749
+ if (!label.trim())
3750
+ break;
3751
+ const tokenEnv = paddleTokenEnvForAccount(index, label);
3752
+ const apiKey = await maybePromptSecret(rl, `Paste ${tokenEnv} into this local terminal`, tokenEnv);
3753
+ if (apiKey)
3754
+ secrets[tokenEnv] = apiKey;
3755
+ accounts.push({
3756
+ id: paddleAccountIdFromLabel(label, index),
3757
+ label: label.trim(),
3758
+ tokenEnv,
3759
+ environment: 'live',
3760
+ });
3761
+ index += 1;
3762
+ const addAnother = await askYesNo(rl, 'Add another Paddle account?', false);
3763
+ if (!addAnother)
3764
+ break;
3765
+ }
3766
+ return accounts;
3448
3767
  }
3449
3768
  async function guideSeoConnector(rl, secrets) {
3450
3769
  printSection('SEO / Google Search Console / DataForSEO', [
3451
- 'Use this when OpenClaw should read organic search demand, GSC clicks/impressions/CTR/position, and optional paid keyword ideas.',
3452
- ]);
3453
- 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');
3454
- printBullets([
3455
- 'Preferred: give the token/service account access to all Search Console properties you want analyzed.',
3456
- 'Leave the property URL empty to let the exporter list and query all verified GSC properties in the account.',
3457
- 'Enter a property URL only when you intentionally want to restrict analysis to one site.',
3458
- 'For OAuth token mode, paste a read-only Search Console token with `webmasters.readonly` scope.',
3459
- '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.',
3460
- 'DataForSEO is optional and paid. The exporter refuses paid calls unless the source command includes --confirm-paid and a small --max-paid-requests cap.',
3461
- 'CSV-only mode is also supported with --gsc-csv or --csv in sources.seo.command.',
3770
+ `${bold('GSC')}: https://search.google.com/search-console`,
3771
+ `${bold('Service account')}: https://console.cloud.google.com/iam-admin/serviceaccounts`,
3772
+ `${bold('Optional paid keyword data')}: https://app.dataforseo.com/api-dashboard`,
3773
+ `${bold('Default')}: leave property URL empty to use all verified GSC properties.`,
3462
3774
  ]);
3463
3775
  const siteUrl = await ask(rl, 'Optional GSC property URL (empty = all verified properties)', process.env.GSC_SITE_URL || '');
3464
3776
  if (siteUrl.trim())
@@ -3476,10 +3788,15 @@ async function guideSeoConnector(rl, secrets) {
3476
3788
  secrets.DATAFORSEO_PASSWORD = password;
3477
3789
  }
3478
3790
  }
3479
- function buildAccountSignalExtraSourceConfig(key, existing = {}) {
3791
+ function buildAccountSignalExtraSourceConfig(key, existing = {}, accounts = []) {
3480
3792
  const definition = getAccountSignalConnectorDefinition(key);
3481
3793
  if (!definition)
3482
3794
  return existing;
3795
+ const accountConfig = accounts.length > 0
3796
+ ? {
3797
+ accounts: mergeConnectorAccounts(existing.accounts, accounts),
3798
+ }
3799
+ : {};
3483
3800
  return {
3484
3801
  ...buildExtraSourceConfig(definition.service, {
3485
3802
  key: definition.key,
@@ -3503,9 +3820,28 @@ function buildAccountSignalExtraSourceConfig(key, existing = {}) {
3503
3820
  signalKind: definition.sourceKind,
3504
3821
  experimental: Boolean(definition.experimental),
3505
3822
  hint: existing.hint || definition.signalHint,
3823
+ ...accountConfig,
3506
3824
  };
3507
3825
  }
3508
- async function upsertAccountSignalConnectorConfig(configPath, key) {
3826
+ function mergeConnectorAccounts(existingAccounts, nextAccounts) {
3827
+ const merged = new Map();
3828
+ for (const account of Array.isArray(existingAccounts) ? existingAccounts : []) {
3829
+ const id = String(account?.id || account?.key || account?.label || '').trim();
3830
+ if (id)
3831
+ merged.set(id, account);
3832
+ }
3833
+ for (const account of nextAccounts) {
3834
+ const id = String(account?.id || account?.key || account?.label || '').trim();
3835
+ if (!id)
3836
+ continue;
3837
+ merged.set(id, {
3838
+ ...(merged.get(id) || {}),
3839
+ ...account,
3840
+ });
3841
+ }
3842
+ return [...merged.values()];
3843
+ }
3844
+ async function upsertAccountSignalConnectorConfig(configPath, key, accounts = []) {
3509
3845
  const definition = getAccountSignalConnectorDefinition(key);
3510
3846
  if (!definition)
3511
3847
  return false;
@@ -3514,7 +3850,7 @@ async function upsertAccountSignalConnectorConfig(configPath, key) {
3514
3850
  const extra = Array.isArray(sources.extra) ? sources.extra : [];
3515
3851
  const nextExtra = extra.filter((source) => String(source?.key || source?.service || '') !== definition.key);
3516
3852
  const existing = extra.find((source) => String(source?.key || source?.service || '') === definition.key) || {};
3517
- nextExtra.push(buildAccountSignalExtraSourceConfig(key, existing));
3853
+ nextExtra.push(buildAccountSignalExtraSourceConfig(key, existing, accounts));
3518
3854
  config.sources = {
3519
3855
  ...sources,
3520
3856
  extra: nextExtra,
@@ -3522,33 +3858,62 @@ async function upsertAccountSignalConnectorConfig(configPath, key) {
3522
3858
  await writeJsonFile(configPath, config);
3523
3859
  return true;
3524
3860
  }
3861
+ function accountSignalTokenEnvForAccount(baseEnv, key, index, label) {
3862
+ if (index === 0)
3863
+ return baseEnv;
3864
+ const suffix = toConfigId(label || key, `${key}_${index + 1}`).toUpperCase().replace(/[^A-Z0-9]+/g, '_');
3865
+ return `${baseEnv}_${suffix}`.replace(/_+/g, '_');
3866
+ }
3525
3867
  async function guideAccountSignalConnector(rl, secrets, key) {
3526
3868
  const definition = getAccountSignalConnectorDefinition(key);
3527
3869
  if (!definition)
3528
- return;
3870
+ return [];
3529
3871
  printSection(definition.label, [
3530
- definition.summary,
3531
- 'Setup is account-wide. Do not paste project IDs, app IDs, product IDs, package names, paywall IDs, service names, or tags here.',
3872
+ `${bold('Docs')}: ${definition.docsUrl}`,
3873
+ `${bold('Setup is account-wide')}. Do not paste project IDs, app IDs, product IDs, package names, paywall IDs, service names, or tags here.`,
3874
+ `${bold('Paste only credentials')} below. The agent discovers accounts/apps/projects later.`,
3532
3875
  ]);
3533
- process.stdout.write(`Docs: ${definition.docsUrl}\n\n`);
3534
- 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`);
3876
+ const accounts = [];
3877
+ let index = 0;
3878
+ while (true) {
3879
+ 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, '') : '');
3880
+ if (!label.trim())
3881
+ break;
3882
+ const credentialEnvs = {};
3883
+ for (const credential of definition.credentials) {
3884
+ const envName = accountSignalTokenEnvForAccount(credential.env, key, index, label);
3885
+ const defaultValue = index === 0 ? credential.defaultValue ?? process.env[credential.env] ?? '' : '';
3886
+ const prompt = envName === credential.env ? credential.prompt : `${credential.prompt.replace(credential.env, envName)}`;
3887
+ const value = credential.optional
3888
+ ? await maybePromptSecret(rl, prompt, envName)
3889
+ : await maybePromptSecret(rl, prompt, envName);
3890
+ const finalValue = value || defaultValue;
3891
+ credentialEnvs[credential.env] = envName;
3892
+ if (finalValue)
3893
+ secrets[envName] = finalValue;
3894
+ else if (!credential.optional) {
3895
+ process.stdout.write(`${envName} was not saved. ${definition.label} setup remains pending for ${label}; rerun this wizard when ready.\n`);
3896
+ }
3545
3897
  }
3898
+ accounts.push({
3899
+ id: toConfigId(label, `${key}_${index + 1}`),
3900
+ label: label.trim(),
3901
+ credentialEnvs,
3902
+ tokenEnv: credentialEnvs[definition.credentials[0]?.env] || definition.credentials[0]?.env || null,
3903
+ accountWide: true,
3904
+ projectScope: 'discover_from_account',
3905
+ });
3906
+ index += 1;
3907
+ const addAnother = await askYesNo(rl, `Add another ${definition.label} account?`, false);
3908
+ if (!addAnother)
3909
+ break;
3546
3910
  }
3911
+ return accounts;
3547
3912
  }
3548
3913
  async function guideSentryConnector(rl, secrets) {
3549
3914
  printSection('Sentry / GlitchTip', [
3550
- 'Paste token, org, and base URL. Projects are discovered automatically.',
3551
- 'Use `https://sentry.io` for Sentry Cloud or your GlitchTip/self-hosted base URL.',
3915
+ `${bold('Base URL')}: https://sentry.io for Sentry Cloud, otherwise your GlitchTip/self-hosted URL.`,
3916
+ `${bold('Token + org')} are needed. Project scope remains unpinned.`,
3552
3917
  ]);
3553
3918
  const accounts = [];
3554
3919
  let index = 0;
@@ -3600,7 +3965,7 @@ async function guideSentryConnector(rl, secrets) {
3600
3965
  let verifiedVisibleProjects = false;
3601
3966
  if (discovery.ok && discovery.projects.length > 0) {
3602
3967
  verifiedVisibleProjects = true;
3603
- process.stdout.write(`Found ${discovery.projects.length} visible project(s). Project scope remains unpinned so OpenClaw/Hermes can decide per run.\n`);
3968
+ process.stdout.write(`Found ${discovery.projects.length} visible project(s). Project scope remains unpinned.\n`);
3604
3969
  }
3605
3970
  else {
3606
3971
  const fallbackOrgs = discoveredOrganizations
@@ -3657,19 +4022,12 @@ function normalizeCoolifyBaseUrl(value) {
3657
4022
  }
3658
4023
  async function guideCoolifyConnector(rl, secrets) {
3659
4024
  printSection('Coolify deployment monitoring', [
3660
- 'Use this when OpenClaw should read deployment, resource, server, and health-check signals from Coolify.',
3661
- 'The token should be read-only. Do not use "*" or sensitive-token permissions for normal monitoring.',
4025
+ `${bold('Create read-only API token')} in Coolify.`,
4026
+ `${bold('Do not use * or sensitive-token permissions')} for normal monitoring.`,
3662
4027
  ]);
3663
4028
  const baseUrl = normalizeCoolifyBaseUrl(await ask(rl, 'Coolify base URL', process.env.COOLIFY_BASE_URL || 'https://coolify.wotaso.com'));
3664
4029
  const tokenUrl = baseUrl ? `${baseUrl}/security/api-tokens` : 'https://<your-coolify-host>/security/api-tokens';
3665
- process.stdout.write(`\nToken page: ${tokenUrl}\n\n`);
3666
- printBullets([
3667
- 'Open the Coolify dashboard.',
3668
- 'In the sidebar, go to "Keys & Tokens".',
3669
- 'Open "API tokens".',
3670
- 'Create a new API key/token with read-only permissions.',
3671
- 'Copy the token once and paste it into this local terminal.',
3672
- ]);
4030
+ process.stdout.write(`${bold('Token page')}: ${tokenUrl}\n\n`);
3673
4031
  const token = await maybePromptSecret(rl, 'Paste COOLIFY_API_TOKEN into this local terminal', 'COOLIFY_API_TOKEN');
3674
4032
  if (baseUrl)
3675
4033
  secrets.COOLIFY_BASE_URL = baseUrl;
@@ -3679,56 +4037,131 @@ async function guideCoolifyConnector(rl, secrets) {
3679
4037
  }
3680
4038
  async function guideAscConnector(rl, secrets) {
3681
4039
  printSection('App Store Connect CLI', [
3682
- 'Use this mainly for App Store analytics batch reports, plus builds, TestFlight, reviews, ratings, and store context.',
3683
- 'The normal Growth Engineer path uses App Store Connect API-key reports. Experimental ASC web analytics is not part of setup.',
3684
- ]);
3685
- process.stdout.write('Create an App Store Connect API key here:\n https://appstoreconnect.apple.com/access/integrations/api\n\n');
3686
- process.stdout.write('Roles to choose for this key:\n');
3687
- printBullets([
3688
- 'Required for first setup: Admin, because Apple only allows Admin keys to create the initial Analytics Report Request.',
3689
- 'Required for steady-state report downloads after the request exists: Sales and Reports, Finance, or Admin.',
3690
- 'Recommended: Customer Support, for App Store ratings and review text.',
3691
- 'Recommended: Developer, for builds, TestFlight, and delivery status.',
3692
- 'Optional: App Manager, only if OpenClaw should also read or manage app metadata, pricing, or release settings.',
3693
- 'Least privilege option: run setup once with Admin, then rotate Growth Engineer to a Sales and Reports key for ongoing analytics downloads.',
3694
- ]);
3695
- process.stdout.write('\nWhy Admin is requested during setup:\n');
3696
- printBullets([
3697
- 'Growth Engineer automatically creates an ongoing App Analytics report request when none exists.',
3698
- 'Without that request, Apple will not generate Impressions, Product Page Views, App Units, Conversion Rate, and related report instances.',
3699
- 'A non-Admin key can read existing reports, but creation fails with a forbidden response.',
4040
+ `${bold('Create 2 API keys')} here: https://appstoreconnect.apple.com/access/integrations/api`,
4041
+ `1. ${bold('Reports key')} - role ${bold('Sales and Reports')} - saved for Growth Engineer.`,
4042
+ `2. ${bold('Setup key')} - role ${bold('Admin')} - used once, then revoke.`,
3700
4043
  ]);
3701
- process.stdout.write('\nAfter creating the key, copy these values into this wizard:\n');
4044
+ process.stdout.write(`${bold('Enter the Reports key now:')}\n`);
3702
4045
  printBullets([
3703
- 'Issuer ID from the API keys page.',
3704
- 'Key ID from the API key row or from the downloaded file name: AuthKey_<KEY_ID>.p8.',
3705
- 'Download the .p8 file, open it, then paste the full file content into this terminal.',
3706
- 'If the .p8 is already on this host, leave the content prompt empty and paste the file path instead.',
3707
- 'Vendor Number from App Store Connect Sales and Trends > Reports, needed for Sales and Trends/App Units reports.',
4046
+ `${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.`,
4047
+ `The wizard saves a secure local copy with ${bold('chmod 600')}.`,
4048
+ `${bold('Issuer ID')} from the API keys page. Same value for both keys.`,
4049
+ `${bold('Vendor Number')} from Sales and Trends > Reports.`,
3708
4050
  ]);
3709
- const keyId = await ask(rl, 'ASC_KEY_ID (leave empty to skip)', process.env.ASC_KEY_ID || '');
3710
- const issuerId = await ask(rl, 'ASC_ISSUER_ID (leave empty to skip)', process.env.ASC_ISSUER_ID || '');
3711
- if (keyId.trim())
3712
- secrets.ASC_KEY_ID = keyId.trim();
4051
+ const normalKeyPath = await askAscPrivateKeyPathWithKeyId(rl, {
4052
+ label: 'Reports .p8 path (AuthKey_<KEY_ID>.p8, empty = paste)',
4053
+ defaultValue: process.env.ASC_PRIVATE_KEY_PATH || '',
4054
+ keyLabel: 'the normal reporting key',
4055
+ });
4056
+ let keyId = normalKeyPath.keyId;
4057
+ if (normalKeyPath.privateKeyPath) {
4058
+ const securePrivateKeyPath = await copyAscPrivateKeyToSecurePath(normalKeyPath.privateKeyPath, keyId);
4059
+ secrets.ASC_PRIVATE_KEY_PATH = securePrivateKeyPath;
4060
+ secrets.ASC_PRIVATE_KEY = DELETE_SECRET;
4061
+ secrets.ASC_PRIVATE_KEY_B64 = DELETE_SECRET;
4062
+ secrets.ASC_KEY_ID = keyId;
4063
+ process.stdout.write(`Inferred ASC_KEY_ID=${keyId} from ${path.basename(normalKeyPath.privateKeyPath)}.\n`);
4064
+ process.stdout.write(`Saved secure Reports key copy to ${securePrivateKeyPath} with chmod 600.\n`);
4065
+ }
4066
+ const issuerId = await ask(rl, 'ASC_ISSUER_ID (same for both keys, empty = skip)', process.env.ASC_ISSUER_ID || '');
3713
4067
  if (issuerId.trim())
3714
4068
  secrets.ASC_ISSUER_ID = issuerId.trim();
3715
- const privateKeyContent = await askAscPrivateKeyContent(rl);
3716
- if (privateKeyContent) {
4069
+ if (!normalKeyPath.privateKeyPath) {
4070
+ keyId = await ask(rl, 'ASC_KEY_ID (from AuthKey_<KEY_ID>.p8, empty = skip)', process.env.ASC_KEY_ID || '');
4071
+ if (keyId.trim())
4072
+ secrets.ASC_KEY_ID = keyId.trim();
4073
+ const privateKeyContent = await askAscPrivateKeyContent(rl, {
4074
+ keyLabel: 'the normal reporting key',
4075
+ });
4076
+ if (!privateKeyContent)
4077
+ return await guideAscBootstrapAdminKey(rl, issuerId.trim());
3717
4078
  const privateKeyPath = resolveAscPrivateKeyPath(keyId);
3718
4079
  await fs.mkdir(path.dirname(privateKeyPath), { recursive: true, mode: 0o700 });
3719
4080
  await fs.writeFile(privateKeyPath, privateKeyContent, { encoding: 'utf8', mode: 0o600 });
3720
4081
  await fs.chmod(privateKeyPath, 0o600);
3721
4082
  secrets.ASC_PRIVATE_KEY_PATH = privateKeyPath;
4083
+ secrets.ASC_PRIVATE_KEY = DELETE_SECRET;
4084
+ secrets.ASC_PRIVATE_KEY_B64 = DELETE_SECRET;
3722
4085
  process.stdout.write(`Saved ASC private key to ${privateKeyPath} with chmod 600.\n`);
3723
4086
  }
3724
- else {
3725
- const privateKeyPath = await askAscPrivateKeyPath(rl);
3726
- if (privateKeyPath.trim())
3727
- secrets.ASC_PRIVATE_KEY_PATH = privateKeyPath.trim();
3728
- }
3729
- const vendorNumber = await ask(rl, 'ASC_VENDOR_NUMBER for Sales and Trends/App Units (leave empty to skip)', process.env.ASC_VENDOR_NUMBER || process.env.ASC_ANALYTICS_VENDOR_NUMBER || '');
4087
+ const vendorNumber = await ask(rl, 'ASC_VENDOR_NUMBER (Sales and Trends > Reports)', process.env.ASC_VENDOR_NUMBER || '');
3730
4088
  if (vendorNumber.trim())
3731
4089
  secrets.ASC_VENDOR_NUMBER = vendorNumber.trim();
4090
+ return await guideAscBootstrapAdminKey(rl, issuerId.trim());
4091
+ }
4092
+ async function guideAscBootstrapAdminKey(rl, issuerIdDefault = '') {
4093
+ const bootstrapEnv = {};
4094
+ process.stdout.write(`\n${bold('Enter the Setup Admin key:')}\n`);
4095
+ printBullets([
4096
+ `${bold('Role must be Admin')} so Apple can create the first App Analytics report request.`,
4097
+ `${bold('Use original AuthKey_<KEY_ID>.p8 filename')} so KEY_ID is read automatically.`,
4098
+ `${bold('Not saved')} to secrets.env. The temporary secure copy is deleted after setup.`,
4099
+ ]);
4100
+ const bootstrapKeyPath = await askAscPrivateKeyPathWithKeyId(rl, {
4101
+ label: 'Setup Admin .p8 path (AuthKey_<KEY_ID>.p8, empty = paste)',
4102
+ defaultValue: '',
4103
+ keyLabel: 'the temporary Admin key',
4104
+ });
4105
+ let bootstrapKeyId = bootstrapKeyPath.keyId;
4106
+ let bootstrapIssuerId = String(issuerIdDefault || '').trim();
4107
+ if (!bootstrapIssuerId) {
4108
+ bootstrapIssuerId = await ask(rl, 'ASC_ISSUER_ID (same API keys page)', process.env.ASC_ISSUER_ID || '');
4109
+ }
4110
+ if (bootstrapKeyPath.privateKeyPath) {
4111
+ const secureBootstrapPath = await copyAscPrivateKeyToSecurePath(bootstrapKeyPath.privateKeyPath, bootstrapKeyId, '_bootstrap_admin');
4112
+ bootstrapEnv.ASC_BOOTSTRAP_PRIVATE_KEY_PATH = secureBootstrapPath;
4113
+ process.stdout.write(`Inferred ASC_BOOTSTRAP_KEY_ID=${bootstrapKeyId} from ${path.basename(bootstrapKeyPath.privateKeyPath)}.\n`);
4114
+ process.stdout.write(`Saved secure temporary Admin key copy to ${secureBootstrapPath} with chmod 600.\n`);
4115
+ bootstrapEnv.ASC_BOOTSTRAP_PRIVATE_KEY_DELETE_AFTER_USE = '1';
4116
+ }
4117
+ else {
4118
+ bootstrapKeyId = await ask(rl, 'ASC_BOOTSTRAP_KEY_ID (from AuthKey_<KEY_ID>.p8)', '');
4119
+ }
4120
+ if (!bootstrapKeyId.trim() || !bootstrapIssuerId.trim())
4121
+ return { bootstrapEnv };
4122
+ bootstrapEnv.ASC_BOOTSTRAP_KEY_ID = bootstrapKeyId.trim();
4123
+ bootstrapEnv.ASC_BOOTSTRAP_ISSUER_ID = bootstrapIssuerId.trim();
4124
+ if (!bootstrapKeyPath.privateKeyPath) {
4125
+ const bootstrapPrivateKeyContent = await askAscPrivateKeyContent(rl, {
4126
+ envName: 'ASC_BOOTSTRAP_PRIVATE_KEY',
4127
+ persistLabel: 'ASC_BOOTSTRAP_PRIVATE_KEY_PATH temporarily',
4128
+ keyLabel: 'the temporary Admin key',
4129
+ });
4130
+ if (!bootstrapPrivateKeyContent)
4131
+ return { bootstrapEnv };
4132
+ const bootstrapPrivateKeyPath = resolveAscPrivateKeyPath(bootstrapKeyId, '_bootstrap_admin');
4133
+ await fs.mkdir(path.dirname(bootstrapPrivateKeyPath), { recursive: true, mode: 0o700 });
4134
+ await fs.writeFile(bootstrapPrivateKeyPath, bootstrapPrivateKeyContent, { encoding: 'utf8', mode: 0o600 });
4135
+ await fs.chmod(bootstrapPrivateKeyPath, 0o600);
4136
+ bootstrapEnv.ASC_BOOTSTRAP_PRIVATE_KEY_PATH = bootstrapPrivateKeyPath;
4137
+ bootstrapEnv.ASC_BOOTSTRAP_PRIVATE_KEY_DELETE_AFTER_USE = '1';
4138
+ process.stdout.write(`Saved temporary Admin ASC private key to ${bootstrapPrivateKeyPath} with chmod 600. It will be deleted after the setup check.\n`);
4139
+ }
4140
+ return { bootstrapEnv };
4141
+ }
4142
+ async function cleanupTemporaryAscBootstrapPrivateKey(bootstrapEnv = {}) {
4143
+ const privateKeyPath = String(bootstrapEnv.ASC_BOOTSTRAP_PRIVATE_KEY_PATH || '').trim();
4144
+ const shouldDelete = String(bootstrapEnv.ASC_BOOTSTRAP_PRIVATE_KEY_DELETE_AFTER_USE || '').trim().toLowerCase();
4145
+ if (!privateKeyPath || !['1', 'true', 'yes'].includes(shouldDelete))
4146
+ return;
4147
+ if (privateKeyPath === String(bootstrapEnv.ASC_PRIVATE_KEY_PATH || process.env.ASC_PRIVATE_KEY_PATH || '').trim()) {
4148
+ process.stdout.write('Temporary Admin .p8 path matches the steady-state ASC_PRIVATE_KEY_PATH; leaving it in place.\n');
4149
+ process.stdout.write('You can also revoke the temporary Admin API key in App Store Connect.\n');
4150
+ return;
4151
+ }
4152
+ try {
4153
+ await fs.unlink(privateKeyPath);
4154
+ process.stdout.write(`Deleted temporary Admin .p8 from ${privateKeyPath}.\n`);
4155
+ }
4156
+ catch (error) {
4157
+ if (error?.code === 'ENOENT') {
4158
+ process.stdout.write(`Temporary Admin .p8 was already absent at ${privateKeyPath}.\n`);
4159
+ process.stdout.write('You can also revoke the temporary Admin API key in App Store Connect.\n');
4160
+ return;
4161
+ }
4162
+ process.stdout.write(`Could not delete temporary Admin .p8 at ${privateKeyPath}: ${error instanceof Error ? error.message : String(error)}\n`);
4163
+ }
4164
+ process.stdout.write('You can also revoke the temporary Admin API key in App Store Connect.\n');
3732
4165
  }
3733
4166
  async function shouldRunSelfUpdate(workspaceRoot, force) {
3734
4167
  if (force)
@@ -3852,6 +4285,7 @@ async function runConnectorSetupSteps({ rl, args, selected, healthByConnector, a
3852
4285
  process.stdout.write('\n');
3853
4286
  const secrets = {};
3854
4287
  let sentryAccounts = [];
4288
+ let paddleAccounts = [];
3855
4289
  let coolifyConfig = null;
3856
4290
  if (selected.includes('analytics')) {
3857
4291
  let forceFreshAnalyticsToken = shouldForceFreshAnalyticsToken(healthByConnector);
@@ -3900,12 +4334,13 @@ async function runConnectorSetupSteps({ rl, args, selected, healthByConnector, a
3900
4334
  if (selected.includes('paddle')) {
3901
4335
  while (true) {
3902
4336
  clearTerminal();
3903
- await guidePaddleConnector(rl, secrets);
4337
+ paddleAccounts = await guidePaddleConnector(rl, secrets);
3904
4338
  const check = await runImmediateConnectorHealthCheck({
3905
4339
  rl,
3906
4340
  configPath: args.config,
3907
4341
  connector: 'paddle',
3908
4342
  secrets,
4343
+ paddleAccounts,
3909
4344
  });
3910
4345
  if (!check.retry)
3911
4346
  break;
@@ -3960,13 +4395,16 @@ async function runConnectorSetupSteps({ rl, args, selected, healthByConnector, a
3960
4395
  if (selected.includes('asc')) {
3961
4396
  while (true) {
3962
4397
  clearTerminal();
3963
- await guideAscConnector(rl, secrets);
3964
- const check = await runImmediateConnectorHealthCheck({
4398
+ const ascSetup = await guideAscConnector(rl, secrets);
4399
+ let bootstrapEnv = ascSetup?.bootstrapEnv || {};
4400
+ let check = await runImmediateConnectorHealthCheck({
3965
4401
  rl,
3966
4402
  configPath: args.config,
3967
4403
  connector: 'asc',
3968
4404
  secrets,
4405
+ runtimeEnv: bootstrapEnv,
3969
4406
  });
4407
+ await cleanupTemporaryAscBootstrapPrivateKey(bootstrapEnv);
3970
4408
  if (!check.retry)
3971
4409
  break;
3972
4410
  }
@@ -3974,8 +4412,8 @@ async function runConnectorSetupSteps({ rl, args, selected, healthByConnector, a
3974
4412
  for (const connector of selected.filter(isAccountSignalConnector)) {
3975
4413
  while (true) {
3976
4414
  clearTerminal();
3977
- await guideAccountSignalConnector(rl, secrets, connector);
3978
- await upsertAccountSignalConnectorConfig(args.config, connector);
4415
+ const accountSignalAccounts = await guideAccountSignalConnector(rl, secrets, connector);
4416
+ await upsertAccountSignalConnectorConfig(args.config, connector, accountSignalAccounts);
3979
4417
  const check = await runImmediateConnectorHealthCheck({
3980
4418
  rl,
3981
4419
  configPath: args.config,
@@ -4002,13 +4440,16 @@ async function runConnectorSetupSteps({ rl, args, selected, healthByConnector, a
4002
4440
  process.stdout.write(`Configured ${sentryAccounts.length} Sentry-compatible account(s) in ${args.config}.\n`);
4003
4441
  }
4004
4442
  }
4443
+ if (paddleAccounts.length > 0 && await upsertPaddleAccountsConfig(args.config, paddleAccounts)) {
4444
+ const readiness = await verifyPaddleAccountsConfig(args.config, paddleAccounts);
4445
+ if (readiness.ok) {
4446
+ process.stdout.write(`Configured ${paddleAccounts.length} Paddle account(s) in ${args.config}.\n`);
4447
+ }
4448
+ }
4005
4449
  if (coolifyConfig?.baseUrl && await upsertCoolifyConfig(args.config, coolifyConfig)) {
4006
4450
  process.stdout.write(`Configured Coolify monitoring for ${coolifyConfig.baseUrl} in ${args.config}.\n`);
4007
4451
  }
4008
- const env = {
4009
- ...process.env,
4010
- ...secrets,
4011
- };
4452
+ const env = mergeRuntimeEnv(secrets, selected.includes('asc') ? { ASC_BYPASS_KEYCHAIN: '1' } : {});
4012
4453
  const command = `${nodeRuntimeScriptCommand('openclaw-growth-start.mjs')} --config ${quote(args.config)} --setup-only --connectors ${quote(selected.join(','))} --only-connectors ${quote(selected.join(','))}`;
4013
4454
  let setupResult = await runSetupCommandWithProgress(command, env, selected, 'Testing connector setup...');
4014
4455
  let setupPayload = parseJsonFromStdout(setupResult.stdout);
@@ -4026,6 +4467,19 @@ async function runConnectorSetupSteps({ rl, args, selected, healthByConnector, a
4026
4467
  });
4027
4468
  }
4028
4469
  }
4470
+ if (paddleAccounts.length > 0 && await upsertPaddleAccountsConfig(args.config, paddleAccounts)) {
4471
+ const readiness = await verifyPaddleAccountsConfig(args.config, paddleAccounts);
4472
+ if (readiness.ok) {
4473
+ process.stdout.write(`Paddle account config is up to date in ${args.config}.\n`);
4474
+ }
4475
+ else {
4476
+ postSetupBlockers.push({
4477
+ check: 'connection:paddle',
4478
+ detail: readiness.detail,
4479
+ remediation: 'Rerun Paddle setup so the active config persists sources.paddle.enabled=true and sources.paddle.accounts[].',
4480
+ });
4481
+ }
4482
+ }
4029
4483
  if (coolifyConfig?.baseUrl && await upsertCoolifyConfig(args.config, coolifyConfig)) {
4030
4484
  process.stdout.write(`Coolify config is up to date in ${args.config}.\n`);
4031
4485
  }