@analyticscli/growth-engineer 0.1.0-preview.9 → 0.1.0

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.
Files changed (40) hide show
  1. package/dist/config.d.ts +925 -45
  2. package/dist/config.js +58 -6
  3. package/dist/config.js.map +1 -1
  4. package/dist/index.js +134 -21
  5. package/dist/index.js.map +1 -1
  6. package/dist/runtime/export-asc-summary.mjs +295 -4
  7. package/dist/runtime/export-asc-summary.mjs.map +1 -1
  8. package/dist/runtime/export-coolify-summary.d.mts +2 -0
  9. package/dist/runtime/export-coolify-summary.mjs +230 -0
  10. package/dist/runtime/export-coolify-summary.mjs.map +1 -0
  11. package/dist/runtime/export-paddle-summary.d.mts +2 -0
  12. package/dist/runtime/export-paddle-summary.mjs +170 -0
  13. package/dist/runtime/export-paddle-summary.mjs.map +1 -0
  14. package/dist/runtime/export-sentry-summary.mjs +265 -38
  15. package/dist/runtime/export-sentry-summary.mjs.map +1 -1
  16. package/dist/runtime/export-seo-summary.d.mts +2 -0
  17. package/dist/runtime/export-seo-summary.mjs +503 -0
  18. package/dist/runtime/export-seo-summary.mjs.map +1 -0
  19. package/dist/runtime/openclaw-exporters-lib.d.mts +51 -0
  20. package/dist/runtime/openclaw-exporters-lib.mjs +769 -63
  21. package/dist/runtime/openclaw-exporters-lib.mjs.map +1 -1
  22. package/dist/runtime/openclaw-growth-engineer.mjs +163 -4
  23. package/dist/runtime/openclaw-growth-engineer.mjs.map +1 -1
  24. package/dist/runtime/openclaw-growth-env.mjs +5 -0
  25. package/dist/runtime/openclaw-growth-env.mjs.map +1 -1
  26. package/dist/runtime/openclaw-growth-preflight.mjs +446 -30
  27. package/dist/runtime/openclaw-growth-preflight.mjs.map +1 -1
  28. package/dist/runtime/openclaw-growth-runner.mjs +831 -146
  29. package/dist/runtime/openclaw-growth-runner.mjs.map +1 -1
  30. package/dist/runtime/openclaw-growth-shared.d.mts +158 -3
  31. package/dist/runtime/openclaw-growth-shared.mjs +574 -8
  32. package/dist/runtime/openclaw-growth-shared.mjs.map +1 -1
  33. package/dist/runtime/openclaw-growth-start.mjs +802 -39
  34. package/dist/runtime/openclaw-growth-start.mjs.map +1 -1
  35. package/dist/runtime/openclaw-growth-status.mjs +85 -31
  36. package/dist/runtime/openclaw-growth-status.mjs.map +1 -1
  37. package/dist/runtime/openclaw-growth-wizard.mjs +1952 -217
  38. package/dist/runtime/openclaw-growth-wizard.mjs.map +1 -1
  39. package/package.json +3 -1
  40. package/templates/config.example.json +128 -65
@@ -3,13 +3,50 @@ import { existsSync, promises as fs } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import process from 'node:process';
5
5
  import { spawn } from 'node:child_process';
6
+ import { fileURLToPath } from 'node:url';
6
7
  import { classifyServiceKind, getActionMode, getAllSourceEntries, getDefaultSourceCommand, getGitHubActionNoun, getGitHubConnectionSummary, getGitHubRequirementText, shouldAutoCreateGitHubArtifact, } from './openclaw-growth-shared.mjs';
7
8
  import { applyOpenClawSecretRefs, loadOpenClawGrowthSecrets } from './openclaw-growth-env.mjs';
8
9
  const DEFAULT_CONFIG_PATH = 'data/openclaw-growth-engineer/config.json';
9
10
  const DEFAULT_CONNECTION_TIMEOUT_MS = 15_000;
11
+ const RUNTIME_DIR = path.dirname(fileURLToPath(import.meta.url));
10
12
  const ANALYTICSCLI_PACKAGE_SPEC = process.env.ANALYTICSCLI_CLI_PACKAGE || '@analyticscli/cli@preview';
11
13
  const ANALYTICSCLI_NPM_PREFIX = process.env.ANALYTICSCLI_NPM_PREFIX ||
12
14
  (process.env.HOME ? path.join(process.env.HOME, '.local') : path.join(process.cwd(), '.analyticscli-npm'));
15
+ const ACCOUNT_SIGNAL_CONNECTORS = [
16
+ 'stripe',
17
+ 'lemonsqueezy',
18
+ 'adapty',
19
+ 'superwall',
20
+ 'google-play',
21
+ 'datadog',
22
+ 'bugsnag',
23
+ 'intercom',
24
+ 'zendesk',
25
+ 'apple-search-ads',
26
+ 'google-ads',
27
+ 'meta-ads',
28
+ 'tiktok-ads',
29
+ 'vercel',
30
+ 'cloudflare',
31
+ 'resend',
32
+ 'customerio',
33
+ 'mailchimp',
34
+ 'appfollow',
35
+ 'apptweak',
36
+ 'linear',
37
+ 'postiz',
38
+ ];
39
+ const SUPPORTED_CONNECTORS = [
40
+ 'analytics',
41
+ 'github',
42
+ 'asc',
43
+ 'revenuecat',
44
+ 'paddle',
45
+ 'seo',
46
+ 'sentry',
47
+ 'coolify',
48
+ ...ACCOUNT_SIGNAL_CONNECTORS,
49
+ ];
13
50
  function printHelpAndExit(exitCode, reason = null) {
14
51
  if (reason) {
15
52
  process.stderr.write(`${reason}\n\n`);
@@ -26,7 +63,7 @@ Options:
26
63
  --config <file> Config path (default: ${DEFAULT_CONFIG_PATH})
27
64
  --test-connections Run live API/connector smoke checks for enabled channels
28
65
  --only-connectors <list>
29
- Limit live checks to analytics,github,asc,revenuecat,sentry
66
+ Limit live checks to ${SUPPORTED_CONNECTORS.join(',')}
30
67
  --timeout-ms <ms> Connection test timeout in milliseconds (default: ${DEFAULT_CONNECTION_TIMEOUT_MS})
31
68
  --progress-json Emit machine-readable progress events on stderr
32
69
  --json Print JSON only (default)
@@ -97,8 +134,58 @@ function normalizeConnectorKey(value) {
97
134
  return 'asc';
98
135
  if (['revenuecat', 'revenue-cat', 'rc', 'revenuecat-mcp'].includes(normalized))
99
136
  return 'revenuecat';
137
+ if (['paddle', 'paddle-billing', 'billing-metrics', 'web-revenue'].includes(normalized))
138
+ return 'paddle';
139
+ if (['seo', 'gsc', 'google-search-console', 'search-console', 'dataforseo', 'organic-search'].includes(normalized))
140
+ return 'seo';
100
141
  if (['sentry', 'sentry-api', 'sentry-mcp', 'glitchtip', 'crashes', 'errors', 'crash-reporting'].includes(normalized))
101
142
  return 'sentry';
143
+ if (['coolify', 'coolify-api', 'deployment', 'deployments', 'hosting', 'infra', 'infrastructure'].includes(normalized))
144
+ return 'coolify';
145
+ if (['stripe', 'stripe-billing', 'stripe-payments'].includes(normalized))
146
+ return 'stripe';
147
+ if (['lemonsqueezy', 'lemon-squeezy', 'lemon', 'ls'].includes(normalized))
148
+ return 'lemonsqueezy';
149
+ if (['adapty', 'adapty-paywalls', 'adapty-subscriptions'].includes(normalized))
150
+ return 'adapty';
151
+ if (['superwall', 'superwall-paywalls'].includes(normalized))
152
+ return 'superwall';
153
+ if (['google-play', 'google-play-console', 'play-console', 'play-store', 'android-store'].includes(normalized))
154
+ return 'google-play';
155
+ if (['datadog', 'datadog-rum', 'datadog-apm', 'datadog-logs'].includes(normalized))
156
+ return 'datadog';
157
+ if (['bugsnag', 'bugsnag-crashes'].includes(normalized))
158
+ return 'bugsnag';
159
+ if (['intercom', 'intercom-support'].includes(normalized))
160
+ return 'intercom';
161
+ if (['zendesk', 'zendesk-support'].includes(normalized))
162
+ return 'zendesk';
163
+ if (['apple-search-ads', 'apple-ads', 'asa', 'search-ads'].includes(normalized))
164
+ return 'apple-search-ads';
165
+ if (['google-ads', 'adwords'].includes(normalized))
166
+ return 'google-ads';
167
+ if (['meta-ads', 'facebook-ads', 'instagram-ads', 'fb-ads'].includes(normalized))
168
+ return 'meta-ads';
169
+ if (['tiktok-ads', 'tiktok-business', 'tiktok-business-api'].includes(normalized))
170
+ return 'tiktok-ads';
171
+ if (['vercel', 'vercel-deployments', 'vercel-hosting'].includes(normalized))
172
+ return 'vercel';
173
+ if (['cloudflare', 'cf', 'cloudflare-workers', 'cloudflare-pages'].includes(normalized))
174
+ return 'cloudflare';
175
+ if (['resend', 'resend-email'].includes(normalized))
176
+ return 'resend';
177
+ if (['customerio', 'customer-io', 'customer.io', 'cio'].includes(normalized))
178
+ return 'customerio';
179
+ if (['mailchimp', 'mailchimp-marketing'].includes(normalized))
180
+ return 'mailchimp';
181
+ if (['appfollow', 'app-follow'].includes(normalized))
182
+ return 'appfollow';
183
+ if (['apptweak', 'app-tweak'].includes(normalized))
184
+ return 'apptweak';
185
+ if (['linear', 'linear-issues', 'linear-planning'].includes(normalized))
186
+ return 'linear';
187
+ if (['postiz', 'postiz-api', 'social-publishing', 'social-scheduler'].includes(normalized))
188
+ return 'postiz';
102
189
  return null;
103
190
  }
104
191
  function parseConnectorList(value) {
@@ -108,14 +195,10 @@ function parseConnectorList(value) {
108
195
  for (const entry of String(value).split(',')) {
109
196
  const connector = normalizeConnectorKey(entry);
110
197
  if (!connector) {
111
- printHelpAndExit(1, `Unknown connector: ${entry.trim()}. Use analytics, github, asc, revenuecat, sentry, or all.`);
198
+ printHelpAndExit(1, `Unknown connector: ${entry.trim()}. Use ${SUPPORTED_CONNECTORS.join(', ')}, or all.`);
112
199
  }
113
200
  if (connector === 'all') {
114
- connectors.add('analytics');
115
- connectors.add('github');
116
- connectors.add('asc');
117
- connectors.add('revenuecat');
118
- connectors.add('sentry');
201
+ SUPPORTED_CONNECTORS.forEach((supported) => connectors.add(supported));
119
202
  }
120
203
  else {
121
204
  connectors.add(connector);
@@ -129,6 +212,54 @@ function shellQuote(value) {
129
212
  }
130
213
  return `'${String(value).replace(/'/g, `'\\''`)}'`;
131
214
  }
215
+ function resolveRuntimeScriptPath(scriptName) {
216
+ const candidates = [
217
+ path.join(RUNTIME_DIR, scriptName),
218
+ path.join(process.cwd(), 'scripts', scriptName),
219
+ path.join(process.cwd(), 'skills', 'openclaw-growth-engineer', 'scripts', scriptName),
220
+ ];
221
+ for (const candidate of candidates) {
222
+ if (existsSync(candidate))
223
+ return candidate;
224
+ }
225
+ return path.join(RUNTIME_DIR, scriptName);
226
+ }
227
+ function nodeRuntimeScriptCommand(scriptName) {
228
+ return `node ${shellQuote(resolveRuntimeScriptPath(scriptName))}`;
229
+ }
230
+ function replaceLegacyRuntimeScriptCommand(command) {
231
+ const trimmed = String(command || '').trim();
232
+ if (!trimmed)
233
+ return trimmed;
234
+ return trimmed.replace(/^node\s+scripts\/(export-analytics-summary\.mjs|export-revenuecat-summary\.mjs|export-paddle-summary\.mjs|export-seo-summary\.mjs|export-sentry-summary\.mjs|export-asc-summary\.mjs|openclaw-growth-start\.mjs|openclaw-growth-status\.mjs|openclaw-growth-preflight\.mjs|openclaw-growth-runner\.mjs|openclaw-growth-engineer\.mjs)(?=\s|$)/, (_match, scriptName) => nodeRuntimeScriptCommand(scriptName));
235
+ }
236
+ function commandHasConfigArg(command) {
237
+ return /(?:^|\s)--config(?:=|\s|$)/.test(String(command || ''));
238
+ }
239
+ function commandIsBuiltinExporter(command) {
240
+ return /(?:^|\s)(?:node\s+)?(?:\S*\/)?(?:export-analytics-summary|export-revenuecat-summary|export-paddle-summary|export-seo-summary|export-sentry-summary|export-coolify-summary|export-asc-summary)\.mjs(?:\s|$)/.test(String(command || ''));
241
+ }
242
+ function commandSupportsActiveConfig(command) {
243
+ return /(?:^|\s)(?:node\s+)?(?:\S*\/)?(?:export-sentry-summary|export-coolify-summary)\.mjs(?:\s|$)/.test(String(command || ''));
244
+ }
245
+ function withActiveConfigArg(command, configPath) {
246
+ const trimmed = String(command || '').trim();
247
+ if (!trimmed || !configPath || !commandIsBuiltinExporter(trimmed)) {
248
+ return trimmed;
249
+ }
250
+ if (!commandSupportsActiveConfig(trimmed)) {
251
+ return trimmed
252
+ .replace(/(^|\s)--config=(?:"[^"]*"|'[^']*'|\S+)/, '$1')
253
+ .replace(/(^|\s)--config\s+(?:"[^"]*"|'[^']*'|\S+)/, '$1')
254
+ .trim();
255
+ }
256
+ if (commandHasConfigArg(trimmed)) {
257
+ return trimmed
258
+ .replace(/(^|\s)--config=(?:"[^"]*"|'[^']*'|\S+)/, `$1--config ${shellQuote(configPath)}`)
259
+ .replace(/(^|\s)--config\s+(?:"[^"]*"|'[^']*'|\S+)/, `$1--config ${shellQuote(configPath)}`);
260
+ }
261
+ return `${trimmed} --config ${shellQuote(configPath)}`;
262
+ }
132
263
  function resolveShellCommand() {
133
264
  const candidates = [
134
265
  process.env.OPENCLAW_SHELL,
@@ -146,12 +277,21 @@ function resolveShellCommand() {
146
277
  }
147
278
  return 'sh';
148
279
  }
280
+ function hardenUnattendedShellCommand(command) {
281
+ return String(command || '').replace(/(^|[;&|]\s*)sudo(?!\s+-n(?:\s|$))(?=\s|$)/g, '$1sudo -n');
282
+ }
149
283
  function runShell(command, options = {}) {
150
284
  return new Promise((resolve) => {
151
- const child = spawn(resolveShellCommand(), ['-c', command], {
285
+ const child = spawn(resolveShellCommand(), ['-c', hardenUnattendedShellCommand(command)], {
152
286
  stdio: ['ignore', 'pipe', 'pipe'],
153
287
  cwd: options.cwd,
154
- env: options.env ? { ...process.env, ...options.env } : process.env,
288
+ env: {
289
+ ...process.env,
290
+ ...(options.env || {}),
291
+ DEBIAN_FRONTEND: 'noninteractive',
292
+ SUDO_ASKPASS: '/bin/false',
293
+ SUDO_PROMPT: '',
294
+ },
155
295
  });
156
296
  let stdout = '';
157
297
  let stderr = '';
@@ -388,7 +528,8 @@ function isPortableCommandDefault(sourceName, command) {
388
528
  const expected = getDefaultSourceCommand(sourceName);
389
529
  if (!expected)
390
530
  return false;
391
- return String(command || '').trim().startsWith(expected);
531
+ const trimmed = String(command || '').trim();
532
+ return trimmed.startsWith(expected) || replaceLegacyRuntimeScriptCommand(trimmed) !== trimmed;
392
533
  }
393
534
  function truncate(value, max = 240) {
394
535
  const text = String(value || '');
@@ -562,9 +703,47 @@ async function testRevenueCatConnection(revenuecatToken, timeoutMs) {
562
703
  };
563
704
  }
564
705
  }
706
+ async function testPaddleConnection(paddleToken, timeoutMs) {
707
+ if (!paddleToken) {
708
+ return {
709
+ ok: false,
710
+ detail: 'missing token',
711
+ };
712
+ }
713
+ const to = new Date().toISOString().slice(0, 10);
714
+ const fromDate = new Date();
715
+ fromDate.setUTCDate(fromDate.getUTCDate() - 2);
716
+ const from = fromDate.toISOString().slice(0, 10);
717
+ try {
718
+ const response = await fetchWithTimeout(`https://api.paddle.com/metrics/revenue?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`, {
719
+ method: 'GET',
720
+ headers: {
721
+ Accept: 'application/json',
722
+ Authorization: `Bearer ${paddleToken}`,
723
+ 'Paddle-Version': '1',
724
+ },
725
+ }, timeoutMs);
726
+ if (!response.ok) {
727
+ return {
728
+ ok: false,
729
+ detail: `HTTP ${response.status}: ${truncate(response.body)}`,
730
+ };
731
+ }
732
+ return {
733
+ ok: true,
734
+ detail: `HTTP ${response.status}`,
735
+ };
736
+ }
737
+ catch (error) {
738
+ return {
739
+ ok: false,
740
+ detail: error instanceof Error ? error.message : String(error),
741
+ };
742
+ }
743
+ }
565
744
  function describeAnalyticsConnectionFailure(detail, analyticsTokenEnv, hasAnalyticsToken) {
566
745
  if (!hasAnalyticsToken) {
567
- return `AnalyticsCLI needs query access. Run \`node scripts/openclaw-growth-wizard.mjs --connectors analytics\`, create or copy a readonly CLI token in dash.analyticscli.com -> API Keys, and paste it into the local terminal wizard. Raw error: ${detail}`;
746
+ return `AnalyticsCLI needs query access. Run \`npx -y @analyticscli/growth-engineer@preview wizard --connectors analytics\`, create or copy a readonly CLI token in dash.analyticscli.com -> API Keys, and paste it into the local terminal wizard. Raw error: ${detail}`;
568
747
  }
569
748
  return `AnalyticsCLI connection failed with \`${analyticsTokenEnv}\` set. Verify that the pasted readonly CLI token is current and has project access. Raw error: ${detail}`;
570
749
  }
@@ -601,6 +780,62 @@ async function testSentryConnection(sentryToken, timeoutMs, baseUrl = 'https://s
601
780
  };
602
781
  }
603
782
  }
783
+ function normalizeCoolifyBaseUrl(value) {
784
+ const raw = String(value || '').trim().replace(/\/+$/, '');
785
+ if (!raw)
786
+ return '';
787
+ return /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
788
+ }
789
+ function resolveCoolifyApiBaseUrl(baseUrl) {
790
+ const normalized = normalizeCoolifyBaseUrl(baseUrl);
791
+ if (!normalized)
792
+ return '';
793
+ if (/\/api\/v1$/i.test(normalized))
794
+ return normalized;
795
+ if (/\/api$/i.test(normalized))
796
+ return `${normalized}/v1`;
797
+ return `${normalized}/api/v1`;
798
+ }
799
+ async function testCoolifyConnection(coolifyToken, timeoutMs, baseUrl) {
800
+ if (!coolifyToken) {
801
+ return {
802
+ ok: false,
803
+ detail: 'missing token',
804
+ };
805
+ }
806
+ const apiBaseUrl = resolveCoolifyApiBaseUrl(baseUrl);
807
+ if (!apiBaseUrl) {
808
+ return {
809
+ ok: false,
810
+ detail: 'missing base URL',
811
+ };
812
+ }
813
+ try {
814
+ const response = await fetchWithTimeout(`${apiBaseUrl}/applications?limit=1`, {
815
+ method: 'GET',
816
+ headers: {
817
+ Accept: 'application/json',
818
+ Authorization: `Bearer ${coolifyToken}`,
819
+ },
820
+ }, timeoutMs);
821
+ if (!response.ok) {
822
+ return {
823
+ ok: false,
824
+ detail: `HTTP ${response.status}: ${truncate(response.body)}`,
825
+ };
826
+ }
827
+ return {
828
+ ok: true,
829
+ detail: `HTTP ${response.status}`,
830
+ };
831
+ }
832
+ catch (error) {
833
+ return {
834
+ ok: false,
835
+ detail: error instanceof Error ? error.message : String(error),
836
+ };
837
+ }
838
+ }
604
839
  function normalizeSentryAccounts(config, sentryTokenEnv) {
605
840
  const sentrySource = config?.sources?.sentry;
606
841
  const accounts = Array.isArray(sentrySource?.accounts) ? sentrySource.accounts : [];
@@ -612,6 +847,13 @@ function normalizeSentryAccounts(config, sentryTokenEnv) {
612
847
  label: String(account?.label || account?.name || account?.id || `Sentry ${index + 1}`).trim(),
613
848
  tokenEnv: String(account?.tokenEnv || account?.token_env || account?.secretEnv || sentryTokenEnv).trim(),
614
849
  baseUrl: String(account?.baseUrl || account?.base_url || account?.url || 'https://sentry.io').trim(),
850
+ org: String(account?.org || account?.organization || '').trim(),
851
+ projects: Array.isArray(account?.projects)
852
+ ? account.projects.map((project) => String(typeof project === 'string' ? project : project?.project || project?.slug || '').trim()).filter(Boolean)
853
+ : account?.project
854
+ ? [String(account.project).trim()].filter(Boolean)
855
+ : [],
856
+ environment: String(account?.environment || process.env.SENTRY_ENVIRONMENT || 'production').trim(),
615
857
  }));
616
858
  }
617
859
  return [
@@ -620,9 +862,24 @@ function normalizeSentryAccounts(config, sentryTokenEnv) {
620
862
  label: 'Sentry',
621
863
  tokenEnv: sentryTokenEnv,
622
864
  baseUrl: String(process.env.SENTRY_BASE_URL || 'https://sentry.io').trim(),
865
+ org: String(process.env.SENTRY_ORG || '').trim(),
866
+ projects: String(process.env.SENTRY_PROJECT || '').trim() ? [String(process.env.SENTRY_PROJECT).trim()] : [],
867
+ environment: String(process.env.SENTRY_ENVIRONMENT || 'production').trim(),
623
868
  },
624
869
  ];
625
870
  }
871
+ function describeSentryAccountTarget(account) {
872
+ const parts = [
873
+ account.label,
874
+ `id=${account.key}`,
875
+ `baseUrl=${account.baseUrl || 'https://sentry.io'}`,
876
+ account.org ? `org=${account.org}` : null,
877
+ account.projects?.length ? `projects=${account.projects.join(',')}` : null,
878
+ account.environment ? `environment=${account.environment}` : null,
879
+ account.tokenEnv ? `tokenEnv=${account.tokenEnv}` : null,
880
+ ].filter(Boolean);
881
+ return parts.join(' ');
882
+ }
626
883
  async function testGitHubConnection(githubToken, githubRepo, timeoutMs, actionMode) {
627
884
  if (!githubToken) {
628
885
  return {
@@ -727,11 +984,14 @@ async function testCommandSourceJson(command, cwd = process.cwd()) {
727
984
  function onlyAllows(onlyConnectors, connector) {
728
985
  return !Array.isArray(onlyConnectors) || onlyConnectors.length === 0 || onlyConnectors.includes(connector);
729
986
  }
730
- async function runConnectionChecks({ checks, config, timeoutMs, progressJson = false, onlyConnectors = [] }) {
987
+ async function runConnectionChecks({ checks, config, configPath, timeoutMs, progressJson = false, onlyConnectors = [] }) {
731
988
  const tasks = [];
732
989
  const analyticsTokenEnv = getSecretName(config, 'analyticsTokenEnv', 'ANALYTICSCLI_ACCESS_TOKEN');
733
990
  const revenuecatTokenEnv = getSecretName(config, 'revenuecatTokenEnv', 'REVENUECAT_API_KEY');
991
+ const paddleTokenEnv = getSecretName(config, 'paddleTokenEnv', 'PADDLE_API_KEY');
992
+ const gscTokenEnv = getSecretName(config, 'gscTokenEnv', 'GOOGLE_SEARCH_CONSOLE_ACCESS_TOKEN');
734
993
  const sentryTokenEnv = getSecretName(config, 'sentryTokenEnv', 'SENTRY_AUTH_TOKEN');
994
+ const coolifyTokenEnv = getSecretName(config, 'coolifyTokenEnv', 'COOLIFY_API_TOKEN');
735
995
  const feedbackTokenEnv = getSecretName(config, 'feedbackTokenEnv', 'FEEDBACK_API_TOKEN');
736
996
  const githubTokenEnv = getSecretName(config, 'githubTokenEnv', 'GITHUB_TOKEN');
737
997
  const githubRepo = isConfiguredGitHubRepo(config?.project?.githubRepo)
@@ -755,7 +1015,7 @@ async function runConnectionChecks({ checks, config, timeoutMs, progressJson = f
755
1015
  ? analyticsConnection.detail
756
1016
  : describeAnalyticsConnectionFailure(analyticsConnection.detail, analyticsTokenEnv, hasAnalyticsToken), analyticsConnection.ok ? 'pass' : analyticsSource?.mode === 'command' ? 'fail' : 'warn');
757
1017
  if (analyticsSource?.mode === 'command') {
758
- const command = String(analyticsSource.command || '').trim();
1018
+ const command = withActiveConfigArg(replaceLegacyRuntimeScriptCommand(String(analyticsSource.command || '').trim()), configPath);
759
1019
  if (!command) {
760
1020
  addCheck(checks, 'connection:analytics-command', false, 'analytics source uses command mode but no command configured');
761
1021
  }
@@ -798,6 +1058,81 @@ async function runConnectionChecks({ checks, config, timeoutMs, progressJson = f
798
1058
  },
799
1059
  });
800
1060
  }
1061
+ const paddleSource = config.sources?.paddle;
1062
+ if (onlyAllows(onlyConnectors, 'paddle')) {
1063
+ scheduleProgressGroup(tasks, checks, progressJson, {
1064
+ key: 'paddle',
1065
+ label: 'Paddle',
1066
+ detail: 'metrics API auth + revenue read',
1067
+ run: async (groupChecks) => {
1068
+ if (sourceEnabled(config, 'paddle')) {
1069
+ const token = process.env[paddleTokenEnv] || '';
1070
+ if (!token) {
1071
+ addCheck(groupChecks, 'connection:paddle', false, `${paddleTokenEnv} missing (required for live Paddle metrics API test)`, paddleSource?.mode === 'command' ? 'fail' : 'warn');
1072
+ }
1073
+ else {
1074
+ const paddleConnection = await testPaddleConnection(token, timeoutMs);
1075
+ addCheck(groupChecks, 'connection:paddle', paddleConnection.ok, paddleConnection.ok
1076
+ ? `Paddle metrics auth check passed (${paddleConnection.detail})`
1077
+ : `Paddle metrics auth check failed (${paddleConnection.detail})`);
1078
+ }
1079
+ if (paddleSource?.mode === 'command') {
1080
+ const command = withActiveConfigArg(replaceLegacyRuntimeScriptCommand(String(paddleSource.command || '').trim()), configPath);
1081
+ if (!command) {
1082
+ addCheck(groupChecks, 'connection:paddle-command', false, 'paddle source uses command mode but no command configured');
1083
+ }
1084
+ else {
1085
+ const commandCheck = await testCommandSourceJson(`${command} --last 2d --max-signals 1`, commandCwd);
1086
+ addCheck(groupChecks, 'connection:paddle-command', commandCheck.ok, commandCheck.ok
1087
+ ? 'Paddle command smoke test passed'
1088
+ : `Paddle command smoke test failed (${commandCheck.detail})`);
1089
+ }
1090
+ }
1091
+ }
1092
+ else {
1093
+ addCheck(groupChecks, 'connection:paddle', true, 'source disabled');
1094
+ }
1095
+ },
1096
+ });
1097
+ }
1098
+ const seoSource = config.sources?.seo;
1099
+ if (onlyAllows(onlyConnectors, 'seo')) {
1100
+ scheduleProgressGroup(tasks, checks, progressJson, {
1101
+ key: 'seo',
1102
+ label: 'SEO / GSC',
1103
+ detail: 'Search Console auth or CSV/DataForSEO config',
1104
+ run: async (groupChecks) => {
1105
+ if (sourceEnabled(config, 'seo')) {
1106
+ const hasGscCredential = Boolean(process.env[gscTokenEnv] ||
1107
+ process.env.GSC_ACCESS_TOKEN ||
1108
+ process.env.GOOGLE_APPLICATION_CREDENTIALS ||
1109
+ process.env.GSC_SERVICE_ACCOUNT_JSON ||
1110
+ process.env.GOOGLE_SERVICE_ACCOUNT_JSON);
1111
+ addCheck(groupChecks, 'connection:seo:gsc-credentials', hasGscCredential || seoSource?.mode !== 'command', hasGscCredential
1112
+ ? 'GSC credential is configured'
1113
+ : 'GSC credential missing; command can still run in CSV-only mode if --gsc-csv/--csv is configured', hasGscCredential ? 'pass' : 'warn');
1114
+ addCheck(groupChecks, 'connection:seo:gsc-site', true, process.env.GSC_SITE_URL || seoSource?.siteUrl || seoSource?.site_url
1115
+ ? 'GSC site/property is pinned intentionally'
1116
+ : 'no GSC site/property pinned; exporter will list and query all verified Search Console properties', 'pass');
1117
+ if (seoSource?.mode === 'command') {
1118
+ const command = withActiveConfigArg(replaceLegacyRuntimeScriptCommand(String(seoSource.command || '').trim()), configPath);
1119
+ if (!command) {
1120
+ addCheck(groupChecks, 'connection:seo-command', false, 'seo source uses command mode but no command configured');
1121
+ }
1122
+ else {
1123
+ const commandCheck = await testCommandSourceJson(`${command} --row-limit 5 --max-signals 1`, commandCwd);
1124
+ addCheck(groupChecks, 'connection:seo-command', commandCheck.ok, commandCheck.ok
1125
+ ? 'SEO command smoke test passed'
1126
+ : `SEO command smoke test failed (${commandCheck.detail})`, hasGscCredential || /--csv|--gsc-csv/.test(command) ? 'fail' : 'warn');
1127
+ }
1128
+ }
1129
+ }
1130
+ else {
1131
+ addCheck(groupChecks, 'connection:seo', true, 'source disabled');
1132
+ }
1133
+ },
1134
+ });
1135
+ }
801
1136
  const sentrySource = config.sources?.sentry;
802
1137
  if (onlyAllows(onlyConnectors, 'sentry')) {
803
1138
  scheduleProgressGroup(tasks, checks, progressJson, {
@@ -810,17 +1145,18 @@ async function runConnectionChecks({ checks, config, timeoutMs, progressJson = f
810
1145
  for (const account of sentryAccounts) {
811
1146
  const token = process.env[account.tokenEnv] || '';
812
1147
  const checkName = sentryAccounts.length > 1 ? `connection:sentry:${account.key}` : 'connection:sentry';
1148
+ const accountTarget = describeSentryAccountTarget(account);
813
1149
  if (!token) {
814
- addCheck(groupChecks, checkName, false, `${account.tokenEnv} missing (required for live Sentry API test for ${account.label})`, sentrySource?.mode === 'command' ? 'fail' : 'warn');
1150
+ addCheck(groupChecks, checkName, false, `${account.tokenEnv} missing (required for live Sentry API test for ${accountTarget})`, sentrySource?.mode === 'command' ? 'fail' : 'warn');
815
1151
  continue;
816
1152
  }
817
1153
  const sentryConnection = await testSentryConnection(token, timeoutMs, account.baseUrl);
818
1154
  addCheck(groupChecks, checkName, sentryConnection.ok, sentryConnection.ok
819
- ? `${account.label} auth check passed (${sentryConnection.detail})`
820
- : `${account.label} auth check failed (${sentryConnection.detail})`);
1155
+ ? `${accountTarget} auth check passed (${sentryConnection.detail})`
1156
+ : `${accountTarget} auth check failed (${sentryConnection.detail})`);
821
1157
  }
822
1158
  if (sentrySource?.mode === 'command') {
823
- const command = String(sentrySource.command || '').trim();
1159
+ const command = withActiveConfigArg(replaceLegacyRuntimeScriptCommand(String(sentrySource.command || '').trim()), configPath);
824
1160
  if (!command) {
825
1161
  addCheck(groupChecks, 'connection:sentry-command', false, 'sentry source uses command mode but no command configured');
826
1162
  }
@@ -828,12 +1164,56 @@ async function runConnectionChecks({ checks, config, timeoutMs, progressJson = f
828
1164
  const commandCheck = await testCommandSourceJson(`${command} --limit 1 --max-signals 1 --last 24h`, commandCwd);
829
1165
  addCheck(groupChecks, 'connection:sentry-command', commandCheck.ok, commandCheck.ok
830
1166
  ? 'Sentry command smoke test passed'
831
- : `Sentry command smoke test failed (${commandCheck.detail})`);
1167
+ : `Sentry command smoke test failed (${commandCheck.detail}); configured accounts: ${sentryAccounts.map(describeSentryAccountTarget).join(' | ')}`);
1168
+ }
1169
+ }
1170
+ }
1171
+ else {
1172
+ const requiredByConnectorSetup = onlyAllows(onlyConnectors, 'sentry') && onlyConnectors.length > 0;
1173
+ addCheck(groupChecks, 'connection:sentry', !requiredByConnectorSetup, requiredByConnectorSetup
1174
+ ? 'selected Sentry connector is still disabled in sources.sentry'
1175
+ : 'source disabled');
1176
+ }
1177
+ },
1178
+ });
1179
+ }
1180
+ const coolifySource = config.sources?.coolify;
1181
+ if (onlyAllows(onlyConnectors, 'coolify')) {
1182
+ scheduleProgressGroup(tasks, checks, progressJson, {
1183
+ key: 'coolify',
1184
+ label: 'Coolify',
1185
+ detail: 'API key auth + deployment/resource read',
1186
+ run: async (groupChecks) => {
1187
+ if (sourceEnabled(config, 'coolify')) {
1188
+ const token = process.env[coolifySource?.tokenEnv || coolifySource?.secretEnv || coolifyTokenEnv] || '';
1189
+ const baseUrl = String(coolifySource?.baseUrl || coolifySource?.base_url || process.env.COOLIFY_BASE_URL || '').trim();
1190
+ if (!token) {
1191
+ addCheck(groupChecks, 'connection:coolify', false, `${coolifySource?.tokenEnv || coolifySource?.secretEnv || coolifyTokenEnv} missing (required for live Coolify API test)`, coolifySource?.mode === 'command' ? 'fail' : 'warn');
1192
+ }
1193
+ else if (!baseUrl) {
1194
+ addCheck(groupChecks, 'connection:coolify', false, 'COOLIFY_BASE_URL or sources.coolify.baseUrl missing (required for live Coolify API test)', coolifySource?.mode === 'command' ? 'fail' : 'warn');
1195
+ }
1196
+ else {
1197
+ const coolifyConnection = await testCoolifyConnection(token, timeoutMs, baseUrl);
1198
+ addCheck(groupChecks, 'connection:coolify', coolifyConnection.ok, coolifyConnection.ok
1199
+ ? `Coolify auth check passed (${coolifyConnection.detail})`
1200
+ : `Coolify auth check failed (${coolifyConnection.detail})`);
1201
+ }
1202
+ if (coolifySource?.mode === 'command') {
1203
+ const command = withActiveConfigArg(replaceLegacyRuntimeScriptCommand(String(coolifySource.command || '').trim()), configPath);
1204
+ if (!command) {
1205
+ addCheck(groupChecks, 'connection:coolify-command', false, 'coolify source uses command mode but no command configured');
1206
+ }
1207
+ else {
1208
+ const commandCheck = await testCommandSourceJson(`${command} --limit 1 --max-signals 1 --last 24h`, commandCwd);
1209
+ addCheck(groupChecks, 'connection:coolify-command', commandCheck.ok, commandCheck.ok
1210
+ ? 'Coolify command smoke test passed'
1211
+ : `Coolify command smoke test failed (${commandCheck.detail})`);
832
1212
  }
833
1213
  }
834
1214
  }
835
1215
  else {
836
- addCheck(groupChecks, 'connection:sentry', true, 'source disabled');
1216
+ addCheck(groupChecks, 'connection:coolify', true, 'source disabled');
837
1217
  }
838
1218
  },
839
1219
  });
@@ -843,7 +1223,7 @@ async function runConnectionChecks({ checks, config, timeoutMs, progressJson = f
843
1223
  // Skip feedback during focused connector checks.
844
1224
  }
845
1225
  else if (sourceEnabled(config, 'feedback') && feedbackSource?.mode === 'command') {
846
- const command = String(feedbackSource.command || '').trim();
1226
+ const command = withActiveConfigArg(replaceLegacyRuntimeScriptCommand(String(feedbackSource.command || '').trim()), configPath);
847
1227
  if (!command) {
848
1228
  addCheck(checks, 'connection:feedback', false, 'feedback source uses command mode but no command configured');
849
1229
  }
@@ -867,13 +1247,20 @@ async function runConnectionChecks({ checks, config, timeoutMs, progressJson = f
867
1247
  }
868
1248
  for (const extraSource of getAllSourceEntries(config).filter((source) => !source.builtIn)) {
869
1249
  const serviceKind = classifyServiceKind(extraSource.service || extraSource.key);
870
- const connectorKind = serviceKind === 'store'
871
- ? 'asc'
872
- : serviceKind === 'revenue'
873
- ? 'revenuecat'
874
- : serviceKind === 'crash'
875
- ? 'sentry'
876
- : serviceKind;
1250
+ const explicitConnectorKind = normalizeConnectorKey(extraSource.key || extraSource.service);
1251
+ const connectorKind = explicitConnectorKind && explicitConnectorKind !== 'all'
1252
+ ? explicitConnectorKind
1253
+ : serviceKind === 'store'
1254
+ ? 'asc'
1255
+ : serviceKind === 'revenue'
1256
+ ? 'revenuecat'
1257
+ : serviceKind === 'crash'
1258
+ ? 'sentry'
1259
+ : serviceKind === 'infrastructure'
1260
+ ? 'coolify'
1261
+ : serviceKind === 'seo'
1262
+ ? 'seo'
1263
+ : serviceKind;
877
1264
  if (!onlyAllows(onlyConnectors, connectorKind))
878
1265
  continue;
879
1266
  const checkName = `connection:${extraSource.key}`;
@@ -882,13 +1269,13 @@ async function runConnectionChecks({ checks, config, timeoutMs, progressJson = f
882
1269
  continue;
883
1270
  }
884
1271
  if (extraSource.mode === 'command') {
885
- const command = String(extraSource.command || '').trim();
1272
+ const command = withActiveConfigArg(replaceLegacyRuntimeScriptCommand(String(extraSource.command || '').trim()), configPath);
886
1273
  if (!command) {
887
1274
  addCheck(checks, checkName, false, 'source uses command mode but no command configured');
888
1275
  continue;
889
1276
  }
890
1277
  const smokeCommand = connectorKind === 'asc' && command.includes('export-asc-summary')
891
- ? `${command} --skip-web-analytics --reviews-limit 1 --feedback-limit 1 --max-signals 1`
1278
+ ? `${command} --reviews-limit 1 --feedback-limit 1 --max-signals 1`
892
1279
  : command;
893
1280
  const commandCheck = await testCommandSourceJson(smokeCommand, commandCwd);
894
1281
  addCheck(checks, checkName, commandCheck.ok, commandCheck.ok
@@ -999,7 +1386,7 @@ async function main() {
999
1386
  continue;
1000
1387
  }
1001
1388
  if (source.mode === 'command') {
1002
- const command = String(source.command || '').trim();
1389
+ const command = replaceLegacyRuntimeScriptCommand(String(source.command || '').trim());
1003
1390
  if (!command) {
1004
1391
  addCheck(checks, `source:${sourceName}:command`, false, 'mode=command but no command configured');
1005
1392
  continue;
@@ -1020,6 +1407,26 @@ async function main() {
1020
1407
  const hasRevenuecatToken = Boolean(process.env[revenuecatTokenEnv]);
1021
1408
  addCheck(checks, `secret:${revenuecatTokenEnv}`, hasRevenuecatToken, hasRevenuecatToken ? 'set (required for RevenueCat command mode)' : 'missing (required for RevenueCat command mode)');
1022
1409
  }
1410
+ if (sourceName === 'paddle') {
1411
+ const paddleTokenEnv = getSecretName(config, 'paddleTokenEnv', 'PADDLE_API_KEY');
1412
+ const hasPaddleToken = Boolean(process.env[paddleTokenEnv]);
1413
+ addCheck(checks, `secret:${paddleTokenEnv}`, hasPaddleToken, hasPaddleToken ? 'set (required for Paddle command mode)' : 'missing (required for Paddle command mode)');
1414
+ }
1415
+ if (sourceName === 'seo') {
1416
+ const gscTokenEnv = getSecretName(config, 'gscTokenEnv', 'GOOGLE_SEARCH_CONSOLE_ACCESS_TOKEN');
1417
+ const hasSearchConsoleAuth = Boolean(process.env[gscTokenEnv] ||
1418
+ process.env.GSC_ACCESS_TOKEN ||
1419
+ process.env.GOOGLE_APPLICATION_CREDENTIALS ||
1420
+ process.env.GSC_SERVICE_ACCOUNT_JSON ||
1421
+ process.env.GOOGLE_SERVICE_ACCOUNT_JSON);
1422
+ const commandText = String(source.command || '');
1423
+ const csvOnly = /--csv|--gsc-csv/.test(commandText);
1424
+ addCheck(checks, `secret:${gscTokenEnv}`, hasSearchConsoleAuth || csvOnly, hasSearchConsoleAuth
1425
+ ? 'set or service-account auth configured'
1426
+ : csvOnly
1427
+ ? 'not required for configured CSV-only SEO command'
1428
+ : 'missing (required for GSC API mode; CSV-only mode may use --gsc-csv/--csv)', hasSearchConsoleAuth || csvOnly ? 'pass' : 'warn');
1429
+ }
1023
1430
  if (sourceName === 'sentry') {
1024
1431
  const sentryTokenEnv = getSecretName(config, 'sentryTokenEnv', 'SENTRY_AUTH_TOKEN');
1025
1432
  for (const account of normalizeSentryAccounts(config, sentryTokenEnv)) {
@@ -1029,6 +1436,14 @@ async function main() {
1029
1436
  : `missing (required for ${account.label} Sentry command mode)`);
1030
1437
  }
1031
1438
  }
1439
+ if (sourceName === 'coolify') {
1440
+ const coolifyTokenEnv = getSecretName(config, 'coolifyTokenEnv', 'COOLIFY_API_TOKEN');
1441
+ const tokenEnv = String(source.tokenEnv || source.secretEnv || coolifyTokenEnv).trim();
1442
+ const hasCoolifyToken = Boolean(process.env[tokenEnv]);
1443
+ const hasCoolifyBaseUrl = Boolean(source.baseUrl || source.base_url || process.env.COOLIFY_BASE_URL);
1444
+ addCheck(checks, `secret:${tokenEnv}`, hasCoolifyToken, hasCoolifyToken ? 'set (required for Coolify command mode)' : 'missing (required for Coolify command mode)');
1445
+ addCheck(checks, 'source:coolify:base-url', hasCoolifyBaseUrl, hasCoolifyBaseUrl ? 'configured' : 'missing COOLIFY_BASE_URL or sources.coolify.baseUrl');
1446
+ }
1032
1447
  if (!source.builtIn && source.secretEnv) {
1033
1448
  const hasConnectorToken = Boolean(process.env[source.secretEnv]);
1034
1449
  addCheck(checks, `secret:${source.secretEnv}`, hasConnectorToken, hasConnectorToken
@@ -1081,6 +1496,7 @@ async function main() {
1081
1496
  await runConnectionChecks({
1082
1497
  checks,
1083
1498
  config,
1499
+ configPath,
1084
1500
  progressJson: args.progressJson,
1085
1501
  timeoutMs: args.timeoutMs,
1086
1502
  onlyConnectors: args.onlyConnectors,