@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
@@ -4,17 +4,67 @@ import path from 'node:path';
4
4
  import process from 'node:process';
5
5
  import { spawn } from 'node:child_process';
6
6
  import { fileURLToPath } from 'node:url';
7
- import { getActionMode, getDefaultSourceCommand } from './openclaw-growth-shared.mjs';
7
+ import { buildGrowthRunnerCommand, buildOpenClawCronAddCommand, deriveSchedulerProofPathFromStatePath, deriveStatePathFromConfigPath, getActionMode, getAutomationConfig, getDefaultSourceCommand, getOpenClawCronEditDeliveryCommandFromInspection, buildHermesCronCreateCommand, inspectHermesCronInstall, inspectOpenClawCronInstall, repairOpenClawCronDeliveryStore, } from './openclaw-growth-shared.mjs';
8
8
  import { applyOpenClawSecretRefs, loadOpenClawGrowthSecrets } from './openclaw-growth-env.mjs';
9
9
  const DEFAULT_CONFIG_PATH = 'data/openclaw-growth-engineer/config.json';
10
10
  const DEFAULT_TEMPLATE_PATH = 'data/openclaw-growth-engineer/config.example.json';
11
11
  const DEFAULT_HEARTBEAT_PATH = 'HEARTBEAT.md';
12
+ const DEFAULT_SCHEDULER_PROOF_PATH = 'data/openclaw-growth-engineer/runtime/scheduler-proof.jsonl';
12
13
  const HEARTBEAT_MARKER_START = '<!-- openclaw-growth-engineer:start -->';
13
14
  const HEARTBEAT_MARKER_END = '<!-- openclaw-growth-engineer:end -->';
14
15
  const ANALYTICSCLI_PACKAGE_SPEC = process.env.ANALYTICSCLI_CLI_PACKAGE || '@analyticscli/cli@preview';
15
16
  const ANALYTICSCLI_NPM_PREFIX = process.env.ANALYTICSCLI_NPM_PREFIX ||
16
17
  (process.env.HOME ? path.join(process.env.HOME, '.local') : path.join(process.cwd(), '.analyticscli-npm'));
17
18
  const RUNTIME_DIR = path.dirname(fileURLToPath(import.meta.url));
19
+ const ACCOUNT_SIGNAL_CONNECTORS = [
20
+ 'stripe',
21
+ 'lemonsqueezy',
22
+ 'adapty',
23
+ 'superwall',
24
+ 'google-play',
25
+ 'datadog',
26
+ 'bugsnag',
27
+ 'intercom',
28
+ 'zendesk',
29
+ 'apple-search-ads',
30
+ 'google-ads',
31
+ 'meta-ads',
32
+ 'tiktok-ads',
33
+ 'vercel',
34
+ 'cloudflare',
35
+ 'resend',
36
+ 'customerio',
37
+ 'mailchimp',
38
+ 'appfollow',
39
+ 'apptweak',
40
+ 'linear',
41
+ 'postiz',
42
+ ];
43
+ const SUPPORTED_CONNECTORS = ['analytics', 'github', 'asc', 'revenuecat', 'paddle', 'seo', 'sentry', 'coolify', ...ACCOUNT_SIGNAL_CONNECTORS];
44
+ const ACCOUNT_SIGNAL_SECRET_ENVS = {
45
+ stripe: ['STRIPE_API_KEY'],
46
+ lemonsqueezy: ['LEMON_SQUEEZY_API_KEY'],
47
+ adapty: ['ADAPTY_API_KEY'],
48
+ superwall: ['SUPERWALL_API_KEY'],
49
+ 'google-play': ['GOOGLE_PLAY_SERVICE_ACCOUNT_JSON'],
50
+ datadog: ['DATADOG_API_KEY', 'DATADOG_APP_KEY'],
51
+ bugsnag: ['BUGSNAG_AUTH_TOKEN'],
52
+ intercom: ['INTERCOM_ACCESS_TOKEN'],
53
+ zendesk: ['ZENDESK_SUBDOMAIN', 'ZENDESK_EMAIL', 'ZENDESK_API_TOKEN'],
54
+ 'apple-search-ads': ['APPLE_SEARCH_ADS_REFRESH_TOKEN'],
55
+ 'google-ads': ['GOOGLE_ADS_DEVELOPER_TOKEN', 'GOOGLE_ADS_CLIENT_ID', 'GOOGLE_ADS_CLIENT_SECRET', 'GOOGLE_ADS_REFRESH_TOKEN'],
56
+ 'meta-ads': ['META_ADS_ACCESS_TOKEN'],
57
+ 'tiktok-ads': ['TIKTOK_ADS_ACCESS_TOKEN'],
58
+ vercel: ['VERCEL_ACCESS_TOKEN'],
59
+ cloudflare: ['CLOUDFLARE_API_TOKEN'],
60
+ resend: ['RESEND_API_KEY'],
61
+ customerio: ['CUSTOMERIO_APP_API_KEY'],
62
+ mailchimp: ['MAILCHIMP_API_KEY'],
63
+ appfollow: ['APPFOLLOW_API_TOKEN'],
64
+ apptweak: ['APPTWEAK_API_TOKEN'],
65
+ linear: ['LINEAR_API_KEY'],
66
+ postiz: ['POSTIZ_API_KEY'],
67
+ };
18
68
  function printHelpAndExit(exitCode, reason = null) {
19
69
  if (reason) {
20
70
  process.stderr.write(`${reason}\n\n`);
@@ -34,25 +84,45 @@ Options:
34
84
  --config <file> Config path (default: ${DEFAULT_CONFIG_PATH})
35
85
  --project <id> Optional AnalyticsCLI project ID pin for generated source commands
36
86
  --asc-app <id> Optional ASC app ID filter (defaults to all accessible apps)
37
- --connectors <list> Install/enable connector helpers (analytics,github,asc,revenuecat,sentry,all)
87
+ --connectors <list> Install/enable connector helpers (${SUPPORTED_CONNECTORS.join(',')},all)
38
88
  --only-connectors <list>
39
- Limit live preflight checks to analytics,github,asc,revenuecat,sentry
89
+ Limit live preflight checks to ${SUPPORTED_CONNECTORS.join(',')}
40
90
  --setup-only Run bootstrap + preflight only (skip first run)
41
91
  --no-test-connections Skip live API smoke checks in preflight
92
+ --openclaw-cron <mode> Configure OpenClaw Gateway cron: auto, enable, require, disable (default: auto)
93
+ --openclaw-cron-schedule <expr>
94
+ Cron expression for OpenClaw Gateway cron (default: */30 * * * *)
95
+ --openclaw-cron-tz <tz>
96
+ Timezone for OpenClaw Gateway cron (default: TZ or UTC)
42
97
  --progress-json Emit machine-readable setup progress to stderr
43
98
  --help, -h Show help
44
99
  `);
45
100
  process.exit(exitCode);
46
101
  }
102
+ function resolveDefaultConfigPath() {
103
+ const explicit = String(process.env.OPENCLAW_GROWTH_CONFIG_PATH || '').trim();
104
+ if (explicit)
105
+ return explicit;
106
+ const homeConfigPath = process.env.HOME ? path.join(process.env.HOME, 'data/openclaw-growth-engineer/config.json') : '';
107
+ const homeStatePath = process.env.HOME ? path.join(process.env.HOME, 'data/openclaw-growth-engineer/state.json') : '';
108
+ if (homeConfigPath && existsSync(homeConfigPath) && existsSync(homeStatePath))
109
+ return homeConfigPath;
110
+ if (!existsSync(DEFAULT_CONFIG_PATH) && homeConfigPath && existsSync(homeConfigPath))
111
+ return homeConfigPath;
112
+ return DEFAULT_CONFIG_PATH;
113
+ }
47
114
  function parseArgs(argv) {
48
115
  const args = {
49
- config: DEFAULT_CONFIG_PATH,
116
+ config: resolveDefaultConfigPath(),
50
117
  project: '',
51
118
  ascApp: '',
52
119
  run: true,
53
120
  testConnections: true,
54
121
  connectors: [],
55
122
  onlyConnectors: [],
123
+ openclawCron: String(process.env.OPENCLAW_GROWTH_OPENCLAW_CRON || 'auto').trim().toLowerCase(),
124
+ openclawCronSchedule: '',
125
+ openclawCronTimezone: '',
56
126
  progressJson: false,
57
127
  };
58
128
  for (let i = 0; i < argv.length; i += 1) {
@@ -87,6 +157,24 @@ function parseArgs(argv) {
87
157
  else if (token === '--no-test-connections') {
88
158
  args.testConnections = false;
89
159
  }
160
+ else if (token === '--openclaw-cron') {
161
+ args.openclawCron = String(next || '').trim().toLowerCase() || 'auto';
162
+ i += 1;
163
+ }
164
+ else if (token === '--enable-openclaw-cron') {
165
+ args.openclawCron = 'enable';
166
+ }
167
+ else if (token === '--no-openclaw-cron') {
168
+ args.openclawCron = 'disable';
169
+ }
170
+ else if (token === '--openclaw-cron-schedule') {
171
+ args.openclawCronSchedule = String(next || '').trim();
172
+ i += 1;
173
+ }
174
+ else if (token === '--openclaw-cron-tz') {
175
+ args.openclawCronTimezone = String(next || '').trim();
176
+ i += 1;
177
+ }
90
178
  else if (token === '--progress-json') {
91
179
  args.progressJson = true;
92
180
  }
@@ -97,8 +185,15 @@ function parseArgs(argv) {
97
185
  printHelpAndExit(1, `Unknown argument: ${token}`);
98
186
  }
99
187
  }
188
+ args.openclawCron = validateOpenClawCronMode(args.openclawCron);
100
189
  return args;
101
190
  }
191
+ function validateOpenClawCronMode(value) {
192
+ const mode = String(value || 'auto').trim().toLowerCase();
193
+ if (['auto', 'enable', 'require', 'disable'].includes(mode))
194
+ return mode;
195
+ printHelpAndExit(1, `Invalid --openclaw-cron mode: ${value}. Use auto, enable, require, or disable.`);
196
+ }
102
197
  function normalizeConnectorKey(value) {
103
198
  const normalized = String(value || '').trim().toLowerCase().replace(/[_\s]+/g, '-');
104
199
  if (!normalized)
@@ -113,8 +208,58 @@ function normalizeConnectorKey(value) {
113
208
  return 'asc';
114
209
  if (['revenuecat', 'revenue-cat', 'rc', 'revenuecat-mcp'].includes(normalized))
115
210
  return 'revenuecat';
211
+ if (['paddle', 'paddle-billing', 'billing-metrics', 'web-revenue'].includes(normalized))
212
+ return 'paddle';
213
+ if (['seo', 'gsc', 'google-search-console', 'search-console', 'dataforseo', 'organic-search'].includes(normalized))
214
+ return 'seo';
116
215
  if (['sentry', 'sentry-api', 'sentry-mcp', 'crashes', 'errors', 'crash-reporting'].includes(normalized))
117
216
  return 'sentry';
217
+ if (['coolify', 'coolify-api', 'deployment', 'deployments', 'hosting', 'infra', 'infrastructure'].includes(normalized))
218
+ return 'coolify';
219
+ if (['stripe', 'stripe-billing', 'stripe-payments'].includes(normalized))
220
+ return 'stripe';
221
+ if (['lemonsqueezy', 'lemon-squeezy', 'lemon', 'ls'].includes(normalized))
222
+ return 'lemonsqueezy';
223
+ if (['adapty', 'adapty-paywalls', 'adapty-subscriptions'].includes(normalized))
224
+ return 'adapty';
225
+ if (['superwall', 'superwall-paywalls'].includes(normalized))
226
+ return 'superwall';
227
+ if (['google-play', 'google-play-console', 'play-console', 'play-store', 'android-store'].includes(normalized))
228
+ return 'google-play';
229
+ if (['datadog', 'datadog-rum', 'datadog-apm', 'datadog-logs'].includes(normalized))
230
+ return 'datadog';
231
+ if (['bugsnag', 'bugsnag-crashes'].includes(normalized))
232
+ return 'bugsnag';
233
+ if (['intercom', 'intercom-support'].includes(normalized))
234
+ return 'intercom';
235
+ if (['zendesk', 'zendesk-support'].includes(normalized))
236
+ return 'zendesk';
237
+ if (['apple-search-ads', 'apple-ads', 'asa', 'search-ads'].includes(normalized))
238
+ return 'apple-search-ads';
239
+ if (['google-ads', 'adwords'].includes(normalized))
240
+ return 'google-ads';
241
+ if (['meta-ads', 'facebook-ads', 'instagram-ads', 'fb-ads'].includes(normalized))
242
+ return 'meta-ads';
243
+ if (['tiktok-ads', 'tiktok-business', 'tiktok-business-api'].includes(normalized))
244
+ return 'tiktok-ads';
245
+ if (['vercel', 'vercel-deployments', 'vercel-hosting'].includes(normalized))
246
+ return 'vercel';
247
+ if (['cloudflare', 'cf', 'cloudflare-workers', 'cloudflare-pages'].includes(normalized))
248
+ return 'cloudflare';
249
+ if (['resend', 'resend-email'].includes(normalized))
250
+ return 'resend';
251
+ if (['customerio', 'customer-io', 'customer.io', 'cio'].includes(normalized))
252
+ return 'customerio';
253
+ if (['mailchimp', 'mailchimp-marketing'].includes(normalized))
254
+ return 'mailchimp';
255
+ if (['appfollow', 'app-follow'].includes(normalized))
256
+ return 'appfollow';
257
+ if (['apptweak', 'app-tweak'].includes(normalized))
258
+ return 'apptweak';
259
+ if (['linear', 'linear-issues', 'linear-planning'].includes(normalized))
260
+ return 'linear';
261
+ if (['postiz', 'postiz-api', 'social-publishing', 'social-scheduler'].includes(normalized))
262
+ return 'postiz';
118
263
  return null;
119
264
  }
120
265
  function parseConnectorList(value) {
@@ -124,14 +269,10 @@ function parseConnectorList(value) {
124
269
  for (const entry of String(value).split(',')) {
125
270
  const connector = normalizeConnectorKey(entry);
126
271
  if (!connector) {
127
- printHelpAndExit(1, `Unknown connector: ${entry.trim()}. Use analytics, github, asc, revenuecat, sentry, or all.`);
272
+ printHelpAndExit(1, `Unknown connector: ${entry.trim()}. Use ${SUPPORTED_CONNECTORS.join(', ')}, or all.`);
128
273
  }
129
274
  if (connector === 'all') {
130
- connectors.add('analytics');
131
- connectors.add('github');
132
- connectors.add('asc');
133
- connectors.add('revenuecat');
134
- connectors.add('sentry');
275
+ SUPPORTED_CONNECTORS.forEach((supported) => connectors.add(supported));
135
276
  }
136
277
  else {
137
278
  connectors.add(connector);
@@ -156,6 +297,72 @@ function resolveRuntimeScriptPath(scriptName) {
156
297
  function nodeRuntimeScriptCommand(scriptName) {
157
298
  return `node ${quote(resolveRuntimeScriptPath(scriptName))}`;
158
299
  }
300
+ function getRuntimeSourceCommand(sourceName) {
301
+ const normalized = String(sourceName || '').trim().toLowerCase();
302
+ if (normalized === 'analytics' || normalized === 'analyticscli') {
303
+ return nodeRuntimeScriptCommand('export-analytics-summary.mjs');
304
+ }
305
+ if (normalized === 'revenuecat' || normalized === 'revenue-cat') {
306
+ return nodeRuntimeScriptCommand('export-revenuecat-summary.mjs');
307
+ }
308
+ if (normalized === 'paddle') {
309
+ return nodeRuntimeScriptCommand('export-paddle-summary.mjs');
310
+ }
311
+ if (['seo', 'gsc', 'google-search-console', 'search-console', 'dataforseo'].includes(normalized)) {
312
+ return nodeRuntimeScriptCommand('export-seo-summary.mjs');
313
+ }
314
+ if (normalized === 'sentry' || normalized === 'glitchtip') {
315
+ return nodeRuntimeScriptCommand('export-sentry-summary.mjs');
316
+ }
317
+ if (normalized === 'coolify') {
318
+ return nodeRuntimeScriptCommand('export-coolify-summary.mjs');
319
+ }
320
+ if (['asc', 'asc-cli', 'app-store-connect', 'app_store_connect'].includes(normalized)) {
321
+ return nodeRuntimeScriptCommand('export-asc-summary.mjs');
322
+ }
323
+ return getDefaultSourceCommand(sourceName);
324
+ }
325
+ function replaceLegacyRuntimeScriptCommand(command) {
326
+ const trimmed = String(command || '').trim();
327
+ if (!trimmed)
328
+ return trimmed;
329
+ 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-coolify-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));
330
+ }
331
+ function normalizeSourceCommand(sourceName, source) {
332
+ return replaceLegacyRuntimeScriptCommand(source?.command || '') || getRuntimeSourceCommand(sourceName);
333
+ }
334
+ function migrateRuntimeSourceCommands(config) {
335
+ if (!config || typeof config !== 'object')
336
+ return config;
337
+ const sources = config.sources && typeof config.sources === 'object' ? config.sources : {};
338
+ const nextSources = { ...sources };
339
+ for (const sourceName of ['analytics', 'revenuecat', 'paddle', 'seo', 'sentry', 'coolify']) {
340
+ if (nextSources[sourceName]?.mode === 'command') {
341
+ nextSources[sourceName] = {
342
+ ...nextSources[sourceName],
343
+ command: normalizeSourceCommand(sourceName, nextSources[sourceName]),
344
+ };
345
+ }
346
+ }
347
+ if (Array.isArray(nextSources.extra)) {
348
+ nextSources.extra = nextSources.extra.map((source) => {
349
+ if (!source || source.mode !== 'command')
350
+ return source;
351
+ const service = String(source.service || source.key || '').toLowerCase();
352
+ const sourceName = ['asc', 'asc-cli', 'app-store-connect', 'app_store_connect'].includes(service)
353
+ ? 'asc'
354
+ : service;
355
+ return {
356
+ ...source,
357
+ command: normalizeSourceCommand(sourceName, source),
358
+ };
359
+ });
360
+ }
361
+ return {
362
+ ...config,
363
+ sources: nextSources,
364
+ };
365
+ }
159
366
  function truncate(value, max = 240) {
160
367
  const text = String(value || '');
161
368
  if (text.length <= max)
@@ -184,10 +391,19 @@ function emitProgress(enabled, event) {
184
391
  return;
185
392
  process.stderr.write(`OPENCLAW_PROGRESS ${JSON.stringify(event)}\n`);
186
393
  }
394
+ function hardenUnattendedShellCommand(command) {
395
+ return String(command || '').replace(/(^|[;&|]\s*)sudo(?!\s+-n(?:\s|$))(?=\s|$)/g, '$1sudo -n');
396
+ }
187
397
  function runShellCommand(command, timeoutMs = 120_000, options = {}) {
188
398
  return new Promise((resolve) => {
189
- const child = spawn(resolveShellCommand(), ['-c', command], {
399
+ const child = spawn(resolveShellCommand(), ['-c', hardenUnattendedShellCommand(command)], {
190
400
  stdio: ['ignore', 'pipe', 'pipe'],
401
+ env: {
402
+ ...process.env,
403
+ DEBIAN_FRONTEND: 'noninteractive',
404
+ SUDO_ASKPASS: '/bin/false',
405
+ SUDO_PROMPT: '',
406
+ },
191
407
  });
192
408
  let stdout = '';
193
409
  let stderr = '';
@@ -468,12 +684,15 @@ function isEffectivelyEmptyHeartbeat(value) {
468
684
  function renderHeartbeatBlock(configPath, config) {
469
685
  const interval = formatHeartbeatInterval(getHeartbeatInterval(config));
470
686
  const displayConfigPath = relativeWorkspacePath(configPath);
687
+ const displayStatePath = deriveStatePathFromConfigPath(displayConfigPath);
688
+ const runnerCommand = buildGrowthRunnerCommand(displayConfigPath, displayStatePath);
689
+ const wizardCommand = 'npx -y @analyticscli/growth-engineer@preview wizard --connectors';
471
690
  return `${HEARTBEAT_MARKER_START}
472
691
  tasks:
473
692
 
474
693
  - name: openclaw-growth-engineer-run
475
694
  interval: ${interval}
476
- prompt: "Run \`node scripts/openclaw-growth-runner.mjs --config ${displayConfigPath}\` from the workspace if the config and runtime files exist. The runner owns schedule.cadences, connectorHealthCheckIntervalMinutes, skipIfNoDataChange, and skipIfIssueSetUnchanged. If it reports connector-health alerts, production crashes, generated issues, or actionable growth findings, summarize only the action and evidence. If setup files are missing, tell the user to run \`node scripts/openclaw-growth-wizard.mjs --connectors\`. If there is no actionable output, reply HEARTBEAT_OK."
695
+ prompt: "Run \`${runnerCommand}\` from the workspace if the config and runtime files exist. The runner owns schedule.cadences, connectorHealthCheckIntervalMinutes, skipIfNoDataChange, and skipIfIssueSetUnchanged. If it reports connector-health alerts, production crashes, generated issues, or actionable growth findings, summarize only the action and evidence. If setup files are missing, tell the user to run \`${wizardCommand}\`. If there is no actionable output, reply HEARTBEAT_OK."
477
696
 
478
697
  # Keep this section small. Do not put secrets in HEARTBEAT.md.
479
698
  ${HEARTBEAT_MARKER_END}`;
@@ -511,6 +730,219 @@ async function ensureGrowthHeartbeat(configPath, config) {
511
730
  updated: false,
512
731
  };
513
732
  }
733
+ function applyOpenClawCronOverrides(config, args) {
734
+ const automation = getAutomationConfig(config);
735
+ const cron = {
736
+ ...automation.openclawCron,
737
+ ...(args.openclawCronSchedule ? { schedule: args.openclawCronSchedule } : {}),
738
+ ...(args.openclawCronTimezone ? { timezone: args.openclawCronTimezone } : {}),
739
+ };
740
+ return {
741
+ ...config,
742
+ automation: {
743
+ ...(config?.automation || {}),
744
+ openclawCron: cron,
745
+ },
746
+ };
747
+ }
748
+ function getOpenClawCronProofCommands(configPath) {
749
+ const displayConfigPath = relativeWorkspacePath(configPath);
750
+ const statePath = deriveStatePathFromConfigPath(displayConfigPath);
751
+ const proofPath = deriveSchedulerProofPathFromStatePath(statePath);
752
+ return {
753
+ listCommand: 'openclaw cron list',
754
+ runsCommand: 'openclaw cron runs --id <job-id>',
755
+ stateCommand: `jq '.connectorHealth, .cadences, .lastRunAt, .skippedReason' ${quote(statePath)}`,
756
+ proofCommand: `tail -n 20 ${quote(proofPath)}`,
757
+ manualWakeCommand: `openclaw system event --text ${quote(`Run OpenClaw Growth Engineer now using config ${relativeWorkspacePath(configPath)} and inspect scheduler proof.`)} --mode now`,
758
+ };
759
+ }
760
+ async function ensureOpenClawCronSchedule(configPath, config, mode = 'auto') {
761
+ const normalizedMode = validateOpenClawCronMode(mode);
762
+ const automation = getAutomationConfig(config);
763
+ const proof = getOpenClawCronProofCommands(configPath);
764
+ if (normalizedMode === 'disable' || automation.openclawCron.enabled === false) {
765
+ return {
766
+ ok: true,
767
+ enabled: false,
768
+ installed: false,
769
+ status: 'disabled',
770
+ detail: 'OpenClaw Gateway cron setup disabled',
771
+ proof,
772
+ };
773
+ }
774
+ const openclawPath = await resolveCommandPath('openclaw');
775
+ if (!openclawPath) {
776
+ const detail = 'openclaw CLI not found on PATH; skipping OpenClaw Gateway cron setup';
777
+ return {
778
+ ok: normalizedMode === 'auto',
779
+ enabled: true,
780
+ installed: false,
781
+ status: normalizedMode === 'auto' ? 'skipped' : 'failed',
782
+ detail,
783
+ remediation: 'Run this setup inside the VPS shell where OpenClaw Gateway is installed, or install the openclaw CLI.',
784
+ proof,
785
+ };
786
+ }
787
+ const displayConfigPath = relativeWorkspacePath(configPath);
788
+ const addCommand = buildOpenClawCronAddCommand(displayConfigPath, config);
789
+ const inspection = await inspectOpenClawCronInstall({
790
+ configPath: displayConfigPath,
791
+ config,
792
+ runCommand: runShellCommand,
793
+ readFile: fs.readFile,
794
+ });
795
+ if (inspection.exists && inspection.verified) {
796
+ return {
797
+ ok: true,
798
+ enabled: true,
799
+ installed: true,
800
+ status: 'already_configured_verified',
801
+ detail: `OpenClaw cron job already exists and matches the Growth Engineer runner contract: ${automation.openclawCron.name}`,
802
+ schedule: automation.openclawCron.schedule,
803
+ timezone: automation.openclawCron.timezone,
804
+ source: inspection.source,
805
+ proof,
806
+ };
807
+ }
808
+ if (inspection.exists && inspection.reason === 'delivery_mismatch') {
809
+ const editCommand = getOpenClawCronEditDeliveryCommandFromInspection(inspection, config);
810
+ if (editCommand) {
811
+ const edit = await runShellCommand(editCommand, 60_000);
812
+ if (edit.ok) {
813
+ return {
814
+ ok: true,
815
+ enabled: true,
816
+ installed: true,
817
+ status: 'repaired_delivery_cli',
818
+ detail: `Repaired OpenClaw cron delivery with: ${editCommand}`,
819
+ schedule: automation.openclawCron.schedule,
820
+ timezone: automation.openclawCron.timezone,
821
+ command: editCommand,
822
+ source: inspection.source,
823
+ proof,
824
+ };
825
+ }
826
+ }
827
+ const repair = await repairOpenClawCronDeliveryStore({
828
+ configPath: displayConfigPath,
829
+ config,
830
+ readFile: fs.readFile,
831
+ writeFile: fs.writeFile,
832
+ });
833
+ if (repair.repaired) {
834
+ return {
835
+ ok: true,
836
+ enabled: true,
837
+ installed: true,
838
+ status: 'repaired_delivery',
839
+ detail: `Repaired OpenClaw cron delivery for "${automation.openclawCron.name}" in ${repair.path}`,
840
+ schedule: automation.openclawCron.schedule,
841
+ timezone: automation.openclawCron.timezone,
842
+ source: repair.path,
843
+ command: editCommand || undefined,
844
+ proof,
845
+ };
846
+ }
847
+ }
848
+ const add = await runShellCommand(addCommand, 60_000);
849
+ const existingDetail = inspection.exists
850
+ ? `Existing OpenClaw cron job "${automation.openclawCron.name}" was not verifiably wired to the current runner contract (${inspection.reason} via ${inspection.source}). `
851
+ : '';
852
+ return {
853
+ ok: add.ok || (normalizedMode === 'auto' && !inspection.exists),
854
+ enabled: true,
855
+ installed: add.ok,
856
+ status: add.ok ? (inspection.exists ? 'reconfigured' : 'configured') : inspection.exists ? 'needs_repair' : 'failed',
857
+ detail: add.ok
858
+ ? `${existingDetail}Configured OpenClaw cron job "${automation.openclawCron.name}" (${automation.openclawCron.schedule}, ${automation.openclawCron.timezone})`
859
+ : `${existingDetail}${add.stderr.trim() || add.stdout.trim() || `openclaw cron add exited ${add.code}`}`,
860
+ schedule: automation.openclawCron.schedule,
861
+ timezone: automation.openclawCron.timezone,
862
+ command: addCommand,
863
+ remediation: inspection.exists && !add.ok
864
+ ? `Remove the stale OpenClaw cron job named "${automation.openclawCron.name}" with your installed OpenClaw CLI, then rerun: ${addCommand}`
865
+ : undefined,
866
+ proof,
867
+ };
868
+ }
869
+ async function ensureHermesCronSchedule(configPath, config, mode = 'auto') {
870
+ const normalizedMode = validateOpenClawCronMode(mode);
871
+ const automation = getAutomationConfig(config);
872
+ const displayConfigPath = relativeWorkspacePath(configPath);
873
+ const statePath = deriveStatePathFromConfigPath(displayConfigPath);
874
+ const proofPath = deriveSchedulerProofPathFromStatePath(statePath);
875
+ const workdir = path.resolve(automation.hermesCron.workdir || process.cwd());
876
+ const proof = {
877
+ listCommand: 'hermes cron list',
878
+ statusCommand: 'hermes cron status <job-id>',
879
+ stateCommand: `jq '.connectorHealth, .cadences, .lastRunAt, .skippedReason' ${quote(statePath)}`,
880
+ proofCommand: `tail -n 20 ${quote(proofPath)}`,
881
+ };
882
+ if (normalizedMode === 'disable' || automation.hermesCron.enabled === false) {
883
+ return {
884
+ ok: true,
885
+ enabled: false,
886
+ installed: false,
887
+ status: 'disabled',
888
+ detail: 'Hermes cron setup disabled',
889
+ proof,
890
+ };
891
+ }
892
+ const hermesPath = await resolveCommandPath('hermes');
893
+ if (!hermesPath) {
894
+ return {
895
+ ok: normalizedMode === 'auto',
896
+ enabled: true,
897
+ installed: false,
898
+ status: normalizedMode === 'auto' ? 'skipped' : 'failed',
899
+ detail: 'hermes CLI not found on PATH; skipping Hermes cron setup',
900
+ remediation: 'Run this setup inside the host shell where Hermes Gateway is installed, or install the hermes CLI.',
901
+ proof,
902
+ };
903
+ }
904
+ const createCommand = buildHermesCronCreateCommand(displayConfigPath, config, { workdir });
905
+ const inspection = await inspectHermesCronInstall({
906
+ configPath: displayConfigPath,
907
+ config,
908
+ runCommand: runShellCommand,
909
+ readFile: fs.readFile,
910
+ workdir,
911
+ });
912
+ if (inspection.exists && inspection.verified) {
913
+ return {
914
+ ok: true,
915
+ enabled: true,
916
+ installed: true,
917
+ status: 'already_configured_verified',
918
+ detail: `Hermes cron job already exists and matches the Growth Engineer runner contract: ${automation.hermesCron.name}`,
919
+ schedule: automation.hermesCron.schedule,
920
+ workdir,
921
+ source: inspection.source,
922
+ proof,
923
+ };
924
+ }
925
+ const create = await runShellCommand(createCommand, 60_000);
926
+ const existingDetail = inspection.exists
927
+ ? `Existing Hermes cron job "${automation.hermesCron.name}" was not verifiably wired to the current runner contract (${inspection.reason} via ${inspection.source}). `
928
+ : '';
929
+ return {
930
+ ok: create.ok || (normalizedMode === 'auto' && !inspection.exists),
931
+ enabled: true,
932
+ installed: create.ok,
933
+ status: create.ok ? (inspection.exists ? 'reconfigured' : 'configured') : inspection.exists ? 'needs_repair' : 'failed',
934
+ detail: create.ok
935
+ ? `${existingDetail}Configured Hermes cron job "${automation.hermesCron.name}" (${automation.hermesCron.schedule})`
936
+ : `${existingDetail}${create.stderr.trim() || create.stdout.trim() || `hermes cron create exited ${create.code}`}`,
937
+ schedule: automation.hermesCron.schedule,
938
+ workdir,
939
+ command: createCommand,
940
+ remediation: inspection.exists && !create.ok
941
+ ? `Remove the stale Hermes cron job named "${automation.hermesCron.name}" with your installed Hermes CLI, then rerun: ${createCommand}`
942
+ : undefined,
943
+ proof,
944
+ };
945
+ }
514
946
  async function appendHelperDetail(details, label, result) {
515
947
  if (result.ok) {
516
948
  details.push(`${label}: ok`);
@@ -769,6 +1201,13 @@ async function installSentryConnector() {
769
1201
  details.push('Sentry direct API exporter enabled via node scripts/export-sentry-summary.mjs');
770
1202
  return { connector: 'sentry', ok: true, detail: details.join('; ') };
771
1203
  }
1204
+ async function installCoolifyConnector() {
1205
+ return {
1206
+ connector: 'coolify',
1207
+ ok: true,
1208
+ detail: 'Coolify uses the built-in read-only API exporter; create a token in Coolify Keys & Tokens / API tokens and store COOLIFY_BASE_URL plus COOLIFY_API_TOKEN with the connector wizard',
1209
+ };
1210
+ }
772
1211
  async function installGitHubConnector() {
773
1212
  const details = [];
774
1213
  await installClawHubSkill('github', details);
@@ -809,6 +1248,20 @@ async function installAnalyticsConnector() {
809
1248
  : 'analyticscli binary missing after dependency setup',
810
1249
  };
811
1250
  }
1251
+ function isAccountSignalConnector(connector) {
1252
+ return ACCOUNT_SIGNAL_CONNECTORS.includes(connector);
1253
+ }
1254
+ async function installAccountSignalConnector(connector) {
1255
+ const envs = ACCOUNT_SIGNAL_SECRET_ENVS[connector] || [];
1256
+ const missing = envs.filter((envName) => !String(process.env[envName] || '').trim());
1257
+ return {
1258
+ connector,
1259
+ ok: missing.length === 0,
1260
+ detail: missing.length === 0
1261
+ ? 'account-wide credential is present; project/app/product scope is discovered later'
1262
+ : `missing account-wide credential(s): ${missing.join(', ')}`,
1263
+ };
1264
+ }
812
1265
  async function enableConnectorConfig(configPath, connectors) {
813
1266
  if (connectors.length === 0 || !(await fileExists(configPath)))
814
1267
  return;
@@ -819,17 +1272,35 @@ async function enableConnectorConfig(configPath, connectors) {
819
1272
  sources: {
820
1273
  ...(config.sources || {}),
821
1274
  analytics: connectors.includes('analytics')
822
- ? { ...(config.sources?.analytics || {}), enabled: true, mode: 'command', command: config.sources?.analytics?.command || getDefaultSourceCommand('analytics') }
1275
+ ? { ...(config.sources?.analytics || {}), enabled: true, mode: 'command', command: normalizeSourceCommand('analytics', config.sources?.analytics) }
823
1276
  : config.sources?.analytics,
824
1277
  revenuecat: connectors.includes('revenuecat')
825
- ? { ...(config.sources?.revenuecat || {}), enabled: true, mode: 'command', command: getDefaultSourceCommand('revenuecat') }
1278
+ ? { ...(config.sources?.revenuecat || {}), enabled: true, mode: 'command', command: normalizeSourceCommand('revenuecat', config.sources?.revenuecat) }
826
1279
  : config.sources?.revenuecat,
1280
+ paddle: connectors.includes('paddle')
1281
+ ? { ...(config.sources?.paddle || {}), enabled: true, mode: 'command', command: normalizeSourceCommand('paddle', config.sources?.paddle) }
1282
+ : config.sources?.paddle,
1283
+ seo: connectors.includes('seo')
1284
+ ? { ...(config.sources?.seo || {}), enabled: true, mode: 'command', command: normalizeSourceCommand('seo', config.sources?.seo) }
1285
+ : config.sources?.seo,
827
1286
  sentry: connectors.includes('sentry')
828
- ? { ...(config.sources?.sentry || {}), enabled: true, mode: 'command', command: getDefaultSourceCommand('sentry') }
1287
+ ? { ...(config.sources?.sentry || {}), enabled: true, mode: 'command', command: normalizeSourceCommand('sentry', config.sources?.sentry) }
829
1288
  : config.sources?.sentry,
1289
+ coolify: connectors.includes('coolify')
1290
+ ? {
1291
+ ...(config.sources?.coolify || {}),
1292
+ enabled: true,
1293
+ mode: 'command',
1294
+ command: normalizeSourceCommand('coolify', config.sources?.coolify),
1295
+ baseUrl: config.sources?.coolify?.baseUrl || process.env.COOLIFY_BASE_URL || 'https://coolify.wotaso.com',
1296
+ tokenEnv: config.sources?.coolify?.tokenEnv || 'COOLIFY_API_TOKEN',
1297
+ }
1298
+ : config.sources?.coolify,
830
1299
  extra: extra.map((source) => connectors.includes('asc') && source?.service === 'asc-cli'
831
- ? { ...source, enabled: true, mode: 'command', command: source.command || getDefaultSourceCommand('asc') }
832
- : source),
1300
+ ? { ...source, enabled: true, mode: 'command', command: normalizeSourceCommand('asc', source) }
1301
+ : connectors.includes(String(source?.key || source?.service || '')) && isAccountSignalConnector(String(source?.key || source?.service || ''))
1302
+ ? { ...source, enabled: true, mode: source.mode || 'file' }
1303
+ : source),
833
1304
  },
834
1305
  };
835
1306
  await writeJson(configPath, next);
@@ -846,8 +1317,16 @@ async function installConnectorHelpers(configPath, connectors) {
846
1317
  results.push(await installAscConnector());
847
1318
  if (connector === 'revenuecat')
848
1319
  results.push(await installRevenueCatConnector());
1320
+ if (connector === 'paddle')
1321
+ results.push({ connector, ok: true, detail: 'Paddle uses the built-in metrics exporter; token is checked during preflight' });
1322
+ if (connector === 'seo')
1323
+ results.push({ connector, ok: true, detail: 'SEO/GSC uses the built-in exporter; credentials or CSV inputs are checked during preflight' });
849
1324
  if (connector === 'sentry')
850
1325
  results.push(await installSentryConnector());
1326
+ if (connector === 'coolify')
1327
+ results.push(await installCoolifyConnector());
1328
+ if (isAccountSignalConnector(connector))
1329
+ results.push(await installAccountSignalConnector(connector));
851
1330
  }
852
1331
  return results;
853
1332
  }
@@ -878,7 +1357,9 @@ async function detectGitHubRepo() {
878
1357
  }
879
1358
  async function ensureConfig(configPath) {
880
1359
  if (await fileExists(configPath)) {
881
- const config = await readJson(configPath);
1360
+ const originalConfig = await readJson(configPath);
1361
+ const config = migrateRuntimeSourceCommands(originalConfig);
1362
+ let changed = JSON.stringify(originalConfig.sources || {}) !== JSON.stringify(config.sources || {});
882
1363
  if (!isConfiguredGitHubRepo(config?.project?.githubRepo)) {
883
1364
  const detectedRepo = await detectGitHubRepo();
884
1365
  if (detectedRepo) {
@@ -886,14 +1367,17 @@ async function ensureConfig(configPath) {
886
1367
  ...(config.project || {}),
887
1368
  githubRepo: detectedRepo,
888
1369
  };
889
- await writeJson(configPath, config);
890
- return {
891
- created: false,
892
- configPath,
893
- githubRepo: detectedRepo,
894
- };
1370
+ changed = true;
895
1371
  }
896
1372
  }
1373
+ if (changed) {
1374
+ await writeJson(configPath, config);
1375
+ return {
1376
+ created: false,
1377
+ configPath,
1378
+ githubRepo: config.project?.githubRepo || null,
1379
+ };
1380
+ }
897
1381
  return {
898
1382
  created: false,
899
1383
  configPath,
@@ -917,19 +1401,27 @@ async function ensureConfig(configPath) {
917
1401
  analytics: {
918
1402
  enabled: true,
919
1403
  mode: 'command',
920
- command: getDefaultSourceCommand('analytics'),
1404
+ command: getRuntimeSourceCommand('analytics'),
921
1405
  },
922
1406
  revenuecat: {
923
1407
  ...(template.sources?.revenuecat || {}),
924
1408
  enabled: false,
925
1409
  mode: 'command',
926
- command: getDefaultSourceCommand('revenuecat'),
1410
+ command: getRuntimeSourceCommand('revenuecat'),
927
1411
  },
928
1412
  sentry: {
929
1413
  ...(template.sources?.sentry || {}),
930
1414
  enabled: false,
931
1415
  mode: 'command',
932
- command: getDefaultSourceCommand('sentry'),
1416
+ command: getRuntimeSourceCommand('sentry'),
1417
+ },
1418
+ coolify: {
1419
+ ...(template.sources?.coolify || {}),
1420
+ enabled: false,
1421
+ mode: 'command',
1422
+ command: getRuntimeSourceCommand('coolify'),
1423
+ baseUrl: template.sources?.coolify?.baseUrl || process.env.COOLIFY_BASE_URL || 'https://coolify.wotaso.com',
1424
+ tokenEnv: template.sources?.coolify?.tokenEnv || 'COOLIFY_API_TOKEN',
933
1425
  },
934
1426
  feedback: {
935
1427
  ...(template.sources?.feedback || {}),
@@ -940,7 +1432,7 @@ async function ensureConfig(configPath) {
940
1432
  actions: {
941
1433
  ...template.actions,
942
1434
  mode: 'issue',
943
- autoCreateIssues: false,
1435
+ autoCreateIssues: true,
944
1436
  autoCreatePullRequests: false,
945
1437
  draftPullRequests: true,
946
1438
  proposalBranchPrefix: 'openclaw/proposals',
@@ -1022,6 +1514,15 @@ function appendProjectFlag(command, projectId) {
1022
1514
  function commandHasAscAppFlag(command) {
1023
1515
  return /(^|\s)--app(\s|=|$)/.test(String(command || ''));
1024
1516
  }
1517
+ function removeAscAppFlag(command) {
1518
+ const raw = String(command || '').trim();
1519
+ if (!raw || !commandHasAscAppFlag(raw))
1520
+ return raw;
1521
+ return raw
1522
+ .replace(/(^|\s)--app(?:=(?:"[^"]*"|'[^']*'|\S+)|\s+(?:"[^"]*"|'[^']*'|\S+))/g, ' ')
1523
+ .replace(/\s+/g, ' ')
1524
+ .trim();
1525
+ }
1025
1526
  function appendAscAppFlag(command, appId) {
1026
1527
  const raw = String(command || '').trim();
1027
1528
  if (!raw || commandHasAscAppFlag(raw))
@@ -1091,6 +1592,34 @@ async function configureAscApp(configPath, appId) {
1091
1592
  process.env.ASC_APP_ID = normalizedAppId;
1092
1593
  return changed;
1093
1594
  }
1595
+ async function configureAscAllApps(configPath) {
1596
+ const config = await readJson(configPath);
1597
+ let changed = false;
1598
+ const extraSources = Array.isArray(config?.sources?.extra) ? config.sources.extra : [];
1599
+ for (const source of extraSources) {
1600
+ if (!source || typeof source !== 'object')
1601
+ continue;
1602
+ const service = String(source.service || source.key || '').trim().toLowerCase();
1603
+ if (!['asc', 'asc-cli', 'app-store-connect', 'app_store_connect'].includes(service))
1604
+ continue;
1605
+ if (source.mode === 'command' && source.command) {
1606
+ const nextCommand = removeAscAppFlag(source.command);
1607
+ if (nextCommand !== source.command) {
1608
+ source.command = nextCommand;
1609
+ changed = true;
1610
+ }
1611
+ }
1612
+ }
1613
+ if (config.project && typeof config.project === 'object' && config.project.ascAppId) {
1614
+ delete config.project.ascAppId;
1615
+ changed = true;
1616
+ }
1617
+ if (changed) {
1618
+ await writeJson(configPath, config);
1619
+ }
1620
+ delete process.env.ASC_APP_ID;
1621
+ return changed;
1622
+ }
1094
1623
  function configHasEnabledAscSource(config) {
1095
1624
  const extraSources = Array.isArray(config?.sources?.extra) ? config.sources.extra : [];
1096
1625
  return extraSources.some((source) => {
@@ -1166,11 +1695,7 @@ async function ensureAscAppConfigured(configPath, explicitAppId) {
1166
1695
  if (!configHasEnabledAscSource(config)) {
1167
1696
  return { ok: true, configured: false, changed: false, appId: null, appScope: 'disabled', needsUserInput: false };
1168
1697
  }
1169
- const configuredAppId = normalizeString(config.project?.ascAppId) || normalizeString(process.env.ASC_APP_ID);
1170
- if (configuredAppId) {
1171
- const changed = await configureAscApp(configPath, configuredAppId);
1172
- return { ok: true, configured: true, changed, appId: configuredAppId, appScope: 'single_app', needsUserInput: false };
1173
- }
1698
+ const changed = await configureAscAllApps(configPath);
1174
1699
  const appList = await listAscApps();
1175
1700
  if (!appList.ok) {
1176
1701
  return {
@@ -1185,7 +1710,7 @@ async function ensureAscAppConfigured(configPath, explicitAppId) {
1185
1710
  return {
1186
1711
  ok: true,
1187
1712
  configured: true,
1188
- changed: false,
1713
+ changed,
1189
1714
  appId: null,
1190
1715
  appScope: 'all_accessible_apps',
1191
1716
  apps: appList.apps,
@@ -1193,6 +1718,131 @@ async function ensureAscAppConfigured(configPath, explicitAppId) {
1193
1718
  needsUserInput: false,
1194
1719
  };
1195
1720
  }
1721
+ function extractAscAnalyticsRequestIds(payload) {
1722
+ const candidates = (() => {
1723
+ if (Array.isArray(payload))
1724
+ return payload;
1725
+ if (payload && typeof payload === 'object') {
1726
+ if (Array.isArray(payload.requests))
1727
+ return payload.requests;
1728
+ if (Array.isArray(payload.analyticsReportRequests))
1729
+ return payload.analyticsReportRequests;
1730
+ if (Array.isArray(payload.items))
1731
+ return payload.items;
1732
+ if (Array.isArray(payload.data))
1733
+ return payload.data;
1734
+ }
1735
+ return [];
1736
+ })();
1737
+ const ids = [];
1738
+ for (const candidate of candidates) {
1739
+ if (!candidate || typeof candidate !== 'object')
1740
+ continue;
1741
+ const id = normalizeString(candidate.id) || normalizeString(candidate.requestId) || normalizeString(candidate.request_id);
1742
+ if (id)
1743
+ ids.push(id);
1744
+ }
1745
+ return [...new Set(ids)];
1746
+ }
1747
+ function extractAscAnalyticsRequestId(payload) {
1748
+ if (payload && typeof payload === 'object') {
1749
+ const id = normalizeString(payload.id) || normalizeString(payload.requestId) || normalizeString(payload.request_id);
1750
+ if (id)
1751
+ return id;
1752
+ }
1753
+ return extractAscAnalyticsRequestIds(payload)[0] || null;
1754
+ }
1755
+ async function listAscAnalyticsRequests(appId, state = '') {
1756
+ const stateArg = state ? ` --state ${quote(state)}` : '';
1757
+ const result = await runShellCommand(`asc analytics requests --app ${quote(appId)}${stateArg} --output json`, 60_000);
1758
+ if (!result.ok) {
1759
+ return { ok: false, ids: [], error: result.stderr || `exit ${result.code}` };
1760
+ }
1761
+ return { ok: true, ids: extractAscAnalyticsRequestIds(parseJsonFromStdout(result.stdout)), error: null };
1762
+ }
1763
+ async function ensureAscAnalyticsRequest(appId) {
1764
+ const normalizedAppId = normalizeString(appId);
1765
+ if (!normalizedAppId) {
1766
+ return { ok: true, status: 'skipped', requestId: null, detail: 'no single ASC app configured' };
1767
+ }
1768
+ const completedRequests = await listAscAnalyticsRequests(normalizedAppId, 'COMPLETED');
1769
+ if (!completedRequests.ok) {
1770
+ return { ok: false, status: 'query_failed', requestId: null, error: completedRequests.error };
1771
+ }
1772
+ if (completedRequests.ids.length > 0) {
1773
+ return { ok: true, status: 'completed', requestId: completedRequests.ids[0], detail: `completed request ${completedRequests.ids[0]}` };
1774
+ }
1775
+ const existingRequests = await listAscAnalyticsRequests(normalizedAppId);
1776
+ if (!existingRequests.ok) {
1777
+ return { ok: false, status: 'query_failed', requestId: null, error: existingRequests.error };
1778
+ }
1779
+ if (existingRequests.ids.length > 0) {
1780
+ return { ok: true, status: 'pending', requestId: existingRequests.ids[0], detail: `existing request ${existingRequests.ids[0]} is not completed yet` };
1781
+ }
1782
+ const created = await runShellCommand(`asc analytics request --app ${quote(normalizedAppId)} --access-type ONGOING --output json`, 60_000);
1783
+ if (!created.ok) {
1784
+ return { ok: false, status: 'create_failed', requestId: null, error: created.stderr || `exit ${created.code}` };
1785
+ }
1786
+ const requestId = extractAscAnalyticsRequestId(parseJsonFromStdout(created.stdout));
1787
+ return {
1788
+ ok: true,
1789
+ status: 'created',
1790
+ requestId,
1791
+ detail: requestId
1792
+ ? `created ongoing request ${requestId}; report instances will appear after Apple processing`
1793
+ : 'created ongoing request; report instances will appear after Apple processing',
1794
+ };
1795
+ }
1796
+ async function ensureAscAnalyticsRequestsForAppScope(ascAppSetup) {
1797
+ if (!ascAppSetup?.ok || ascAppSetup.appScope === 'disabled') {
1798
+ return { ok: true, status: 'skipped', requestId: null, requestIds: [], detail: 'ASC source is not enabled', results: [] };
1799
+ }
1800
+ const apps = ascAppSetup.appId
1801
+ ? [{ id: ascAppSetup.appId }]
1802
+ : Array.isArray(ascAppSetup.apps)
1803
+ ? ascAppSetup.apps.filter((app) => normalizeString(app?.id))
1804
+ : [];
1805
+ if (apps.length === 0) {
1806
+ return { ok: true, status: 'skipped', requestId: null, requestIds: [], detail: 'no accessible ASC apps found', results: [] };
1807
+ }
1808
+ const results = [];
1809
+ for (const app of apps) {
1810
+ const result = await ensureAscAnalyticsRequest(app.id);
1811
+ results.push({
1812
+ appId: app.id,
1813
+ appName: app.name || null,
1814
+ ...result,
1815
+ });
1816
+ }
1817
+ const failures = results.filter((result) => !result.ok);
1818
+ if (failures.length > 0) {
1819
+ return {
1820
+ ok: false,
1821
+ status: failures.length === results.length ? 'failed' : 'partial_failed',
1822
+ requestId: null,
1823
+ requestIds: results.map((result) => result.requestId).filter(Boolean),
1824
+ results,
1825
+ error: failures
1826
+ .map((failure) => `${failure.appName || failure.appId}: ${failure.error || failure.status || 'unknown error'}`)
1827
+ .join('; '),
1828
+ };
1829
+ }
1830
+ const counts = results.reduce((memo, result) => {
1831
+ memo[result.status] = (memo[result.status] || 0) + 1;
1832
+ return memo;
1833
+ }, {});
1834
+ const detail = Object.entries(counts)
1835
+ .map(([status, count]) => `${count} ${status}`)
1836
+ .join(', ');
1837
+ return {
1838
+ ok: true,
1839
+ status: 'ok',
1840
+ requestId: results[0]?.requestId || null,
1841
+ requestIds: results.map((result) => result.requestId).filter(Boolean),
1842
+ detail: `checked ${results.length} ASC app(s): ${detail}`,
1843
+ results,
1844
+ };
1845
+ }
1196
1846
  async function listAnalyticsProjects() {
1197
1847
  const result = await runShellCommand('analyticscli projects list --format json', 60_000);
1198
1848
  if (!result.ok) {
@@ -1286,7 +1936,7 @@ function remediationForCheck(checkName, configPath) {
1286
1936
  return 'Write `data/openclaw-growth-engineer/analytics_summary.json` via your analytics refresh step (API-key based source command/file generation).';
1287
1937
  }
1288
1938
  if (checkName === 'connection:analytics') {
1289
- return 'Run `node scripts/openclaw-growth-wizard.mjs --connectors analytics` and paste a fresh AnalyticsCLI readonly CLI token into the local terminal wizard.';
1939
+ return 'Run `npx -y @analyticscli/growth-engineer@preview wizard --connectors analytics` and paste a fresh AnalyticsCLI readonly CLI token into the local terminal wizard.';
1290
1940
  }
1291
1941
  if (checkName === 'connection:github') {
1292
1942
  return 'Verify `GITHUB_TOKEN` and repo access to `/repos/<owner>/<repo>` + issues API.';
@@ -1295,7 +1945,7 @@ function remediationForCheck(checkName, configPath) {
1295
1945
  return 'Verify `GITHUB_TOKEN` and repo access to `/repos/<owner>/<repo>/pulls`, plus `Pull requests: Read/Write` and `Contents: Read/Write` scopes.';
1296
1946
  }
1297
1947
  if (checkName === 'connection:asc_cli') {
1298
- return 'ASC setup should list App Store Connect apps and persist the selected app automatically. Rerun the connector wizard; if this repeats, update the skill/CLI rather than setting ASC_APP_ID by hand.';
1948
+ return 'ASC setup should list all App Store Connect apps the API key can access. Rerun the connector wizard with an API key scoped to the whole account; do not set ASC_APP_ID for normal Growth Engineer runs.';
1299
1949
  }
1300
1950
  return 'Fix this blocker and rerun start.';
1301
1951
  }
@@ -1346,17 +1996,61 @@ async function runPreflight(configPath, testConnections, progressJson = false, o
1346
1996
  };
1347
1997
  }
1348
1998
  async function runFirstPass(configPath) {
1349
- const command = `${nodeRuntimeScriptCommand('openclaw-growth-runner.mjs')} --config ${quote(configPath)}`;
1999
+ const statePath = path.resolve(deriveStatePathFromConfigPath(configPath));
2000
+ const command = `${nodeRuntimeScriptCommand('openclaw-growth-runner.mjs')} --config ${quote(configPath)} --state ${quote(statePath)}`;
1350
2001
  return runShellCommand(command, 300_000);
1351
2002
  }
1352
2003
  async function main() {
1353
2004
  await loadOpenClawGrowthSecrets();
1354
2005
  const args = parseArgs(process.argv.slice(2));
1355
2006
  const configPath = path.resolve(args.config);
2007
+ const schedulerProofPath = path.resolve(deriveSchedulerProofPathFromStatePath(deriveStatePathFromConfigPath(configPath)));
1356
2008
  const configResult = await ensureConfig(configPath);
1357
- const initialConfig = await readJson(configPath);
2009
+ const initialConfig = applyOpenClawCronOverrides(await readJson(configPath), args);
1358
2010
  await applyOpenClawSecretRefs(initialConfig);
1359
2011
  const heartbeat = await ensureGrowthHeartbeat(configPath, initialConfig);
2012
+ const openclawCron = await ensureOpenClawCronSchedule(configPath, initialConfig, args.openclawCron);
2013
+ const hermesCron = await ensureHermesCronSchedule(configPath, initialConfig, args.openclawCron);
2014
+ if (!openclawCron.ok) {
2015
+ process.stdout.write(`${JSON.stringify({
2016
+ ok: false,
2017
+ phase: 'openclaw_cron_setup',
2018
+ configCreated: configResult.created,
2019
+ configPath,
2020
+ heartbeat,
2021
+ openclawCron,
2022
+ hermesCron,
2023
+ blockers: [
2024
+ {
2025
+ check: 'scheduler:openclaw-cron',
2026
+ detail: openclawCron.detail,
2027
+ remediation: openclawCron.remediation || 'Fix OpenClaw cron setup and rerun start.',
2028
+ },
2029
+ ],
2030
+ }, null, 2)}\n`);
2031
+ process.exitCode = 1;
2032
+ return;
2033
+ }
2034
+ if (!hermesCron.ok) {
2035
+ process.stdout.write(`${JSON.stringify({
2036
+ ok: false,
2037
+ phase: 'hermes_cron_setup',
2038
+ configCreated: configResult.created,
2039
+ configPath,
2040
+ heartbeat,
2041
+ openclawCron,
2042
+ hermesCron,
2043
+ blockers: [
2044
+ {
2045
+ check: 'scheduler:hermes-cron',
2046
+ detail: hermesCron.detail,
2047
+ remediation: hermesCron.remediation || 'Fix Hermes cron setup and rerun start.',
2048
+ },
2049
+ ],
2050
+ }, null, 2)}\n`);
2051
+ process.exitCode = 1;
2052
+ return;
2053
+ }
1360
2054
  const projectConfigured = await configureAnalyticsProject(configPath, args.project);
1361
2055
  const ascAppConfiguredFromArg = await configureAscApp(configPath, args.ascApp);
1362
2056
  const analyticscliEnsure = await ensureAnalyticsCliInstalled();
@@ -1367,6 +2061,8 @@ async function main() {
1367
2061
  configCreated: configResult.created,
1368
2062
  configPath,
1369
2063
  heartbeat,
2064
+ openclawCron,
2065
+ hermesCron,
1370
2066
  projectConfigured,
1371
2067
  ascAppConfigured: ascAppConfiguredFromArg,
1372
2068
  blockers: [
@@ -1408,6 +2104,8 @@ async function main() {
1408
2104
  configCreated: configResult.created,
1409
2105
  configPath,
1410
2106
  heartbeat,
2107
+ openclawCron,
2108
+ hermesCron,
1411
2109
  projectConfigured,
1412
2110
  ascAppConfigured: ascAppConfiguredFromArg,
1413
2111
  connectorSetup,
@@ -1422,7 +2120,9 @@ async function main() {
1422
2120
  ? 'Install the ASC CLI and provide ASC_KEY_ID, ASC_ISSUER_ID, and ASC_PRIVATE_KEY_PATH or ASC_PRIVATE_KEY. Resolve the app after auth succeeds.'
1423
2121
  : entry.connector === 'sentry'
1424
2122
  ? 'Set SENTRY_AUTH_TOKEN plus SENTRY_ORG in the connector wizard. Defer project scope to app/repo context, or configure sources.sentry.accounts[].projects[] only when a fixed mapping is known.'
1425
- : 'Set REVENUECAT_API_KEY and rerun connector setup to write RevenueCat MCP config.',
2123
+ : entry.connector === 'coolify'
2124
+ ? 'Set COOLIFY_BASE_URL and COOLIFY_API_TOKEN from Coolify Keys & Tokens / API tokens in the connector wizard.'
2125
+ : 'Set REVENUECAT_API_KEY and rerun connector setup to write RevenueCat MCP config.',
1426
2126
  })),
1427
2127
  }, null, 2)}\n`);
1428
2128
  process.exitCode = 1;
@@ -1475,6 +2175,8 @@ async function main() {
1475
2175
  configCreated: configResult.created,
1476
2176
  configPath,
1477
2177
  heartbeat,
2178
+ openclawCron,
2179
+ hermesCron,
1478
2180
  projectConfigured: projectConfigured || analyticsProjectSetup.configured,
1479
2181
  analyticsProjectId: analyticsProjectSetup.projectId || null,
1480
2182
  ascAppConfigured: false,
@@ -1494,6 +2196,51 @@ async function main() {
1494
2196
  process.exitCode = 1;
1495
2197
  return;
1496
2198
  }
2199
+ emitProgress(args.progressJson, {
2200
+ phase: 'start',
2201
+ key: 'ascAnalyticsRequest',
2202
+ label: 'ASC analytics reports',
2203
+ detail: 'checking ongoing Analytics Report Request',
2204
+ });
2205
+ const ascAnalyticsRequestSetup = await ensureAscAnalyticsRequestsForAppScope(ascAppSetup);
2206
+ emitProgress(args.progressJson, {
2207
+ phase: 'finish',
2208
+ key: 'ascAnalyticsRequest',
2209
+ label: 'ASC analytics reports',
2210
+ detail: ascAnalyticsRequestSetup.ok
2211
+ ? ascAnalyticsRequestSetup.detail
2212
+ : `could not ensure Analytics Report Request (${truncate(ascAnalyticsRequestSetup.error, 240)})`,
2213
+ status: ascAnalyticsRequestSetup.ok ? 'pass' : 'fail',
2214
+ });
2215
+ if (!ascAnalyticsRequestSetup.ok) {
2216
+ process.stdout.write(`${JSON.stringify({
2217
+ ok: false,
2218
+ phase: 'asc_analytics_request_setup',
2219
+ configCreated: configResult.created,
2220
+ configPath,
2221
+ heartbeat,
2222
+ openclawCron,
2223
+ hermesCron,
2224
+ projectConfigured: projectConfigured || analyticsProjectSetup.configured,
2225
+ analyticsProjectId: analyticsProjectSetup.projectId || null,
2226
+ ascAppConfigured: ascAppSetup.configured,
2227
+ ascAppId: ascAppSetup.appId || null,
2228
+ ascAppScope: ascAppSetup.appScope || null,
2229
+ ascAnalyticsRequestResults: ascAnalyticsRequestSetup.results || [],
2230
+ connectorSetup,
2231
+ needsUserInput: false,
2232
+ question: null,
2233
+ blockers: [
2234
+ {
2235
+ check: 'connection:asc_analytics_request',
2236
+ detail: `Could not ensure App Store Connect Analytics Report Request: ${truncate(ascAnalyticsRequestSetup.error, 800)}`,
2237
+ remediation: 'Use an ASC API key with Admin for first setup so Growth Engineer can create the ongoing Analytics Report Request. After the request exists, rotate to Sales and Reports for steady-state downloads.',
2238
+ },
2239
+ ],
2240
+ }, null, 2)}\n`);
2241
+ process.exitCode = 1;
2242
+ return;
2243
+ }
1497
2244
  const preflightResult = await runPreflight(configPath, args.testConnections, args.progressJson, args.onlyConnectors);
1498
2245
  const preflightPayload = preflightResult.payload;
1499
2246
  if (!preflightPayload) {
@@ -1514,11 +2261,16 @@ async function main() {
1514
2261
  configCreated: configResult.created,
1515
2262
  configPath,
1516
2263
  heartbeat,
2264
+ openclawCron,
2265
+ hermesCron,
1517
2266
  projectConfigured: projectConfigured || analyticsProjectSetup.configured,
1518
2267
  analyticsProjectId: analyticsProjectSetup.projectId || null,
1519
2268
  ascAppConfigured: ascAppSetup.configured,
1520
2269
  ascAppId: ascAppSetup.appId || null,
1521
2270
  ascAppScope: ascAppSetup.appScope || null,
2271
+ ascAnalyticsRequestStatus: ascAnalyticsRequestSetup.status,
2272
+ ascAnalyticsRequestId: ascAnalyticsRequestSetup.requestId || null,
2273
+ ascAnalyticsRequestIds: ascAnalyticsRequestSetup.requestIds || [],
1522
2274
  githubRepo: configResult.githubRepo,
1523
2275
  connectorSetup,
1524
2276
  checks: preflightPayload.checks || [],
@@ -1534,12 +2286,18 @@ async function main() {
1534
2286
  configCreated: configResult.created,
1535
2287
  configPath,
1536
2288
  heartbeat,
2289
+ openclawCron,
2290
+ hermesCron,
1537
2291
  projectConfigured: projectConfigured || analyticsProjectSetup.configured,
1538
2292
  analyticsProjectId: analyticsProjectSetup.projectId || null,
1539
2293
  ascAppConfigured: ascAppSetup.configured,
1540
2294
  ascAppId: ascAppSetup.appId || null,
1541
2295
  ascAppScope: ascAppSetup.appScope || null,
2296
+ ascAnalyticsRequestStatus: ascAnalyticsRequestSetup.status,
2297
+ ascAnalyticsRequestId: ascAnalyticsRequestSetup.requestId || null,
2298
+ ascAnalyticsRequestIds: ascAnalyticsRequestSetup.requestIds || [],
1542
2299
  connectorSetup,
2300
+ schedulerProofPath,
1543
2301
  message: 'Preflight passed. First run skipped due to --setup-only.',
1544
2302
  }, null, 2)}\n`);
1545
2303
  return;
@@ -1562,6 +2320,8 @@ async function main() {
1562
2320
  configCreated: configResult.created,
1563
2321
  configPath,
1564
2322
  heartbeat,
2323
+ openclawCron,
2324
+ hermesCron,
1565
2325
  projectConfigured,
1566
2326
  error: rawError,
1567
2327
  }, null, 2)}\n`);
@@ -1575,9 +2335,12 @@ async function main() {
1575
2335
  configCreated: configResult.created,
1576
2336
  configPath,
1577
2337
  heartbeat,
2338
+ openclawCron,
2339
+ hermesCron,
1578
2340
  projectConfigured,
1579
2341
  actionMode,
1580
2342
  runnerOutput: runResult.stdout.trim(),
2343
+ schedulerProofPath,
1581
2344
  }, null, 2)}\n`);
1582
2345
  }
1583
2346
  main().catch((error) => {