@analyticscli/growth-engineer 0.1.0-preview.8 → 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 +847 -150
  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 +816 -41
  34. package/dist/runtime/openclaw-growth-start.mjs.map +1 -1
  35. package/dist/runtime/openclaw-growth-status.mjs +100 -34
  36. package/dist/runtime/openclaw-growth-status.mjs.map +1 -1
  37. package/dist/runtime/openclaw-growth-wizard.mjs +1997 -226
  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
@@ -1,19 +1,57 @@
1
1
  #!/usr/bin/env node
2
- import { promises as fs } from 'node:fs';
2
+ 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
6
  import { createInterface } from 'node:readline/promises';
7
7
  import { emitKeypressEvents } from 'node:readline';
8
8
  import { createPrivateKey } from 'node:crypto';
9
- import { buildExtraSourceConfig, getDefaultSourceCommand, } from './openclaw-growth-shared.mjs';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { buildOpenClawCronAddCommand, buildHermesCronCreateCommand, buildGrowthRunnerCommand, deriveSchedulerProofPathFromStatePath, deriveStatePathFromConfigPath, buildExtraSourceConfig, getAutomationConfig, getDefaultSourceCommand, getDefaultSourcePath, inspectHermesCronInstall, inspectOpenClawCronInstall, } from './openclaw-growth-shared.mjs';
10
11
  import { loadOpenClawGrowthSecrets } from './openclaw-growth-env.mjs';
11
12
  const DEFAULT_CONFIG_PATH = 'data/openclaw-growth-engineer/config.json';
12
13
  const SELF_UPDATE_INTERVAL_MS = 24 * 60 * 60 * 1000;
13
14
  const ENABLE_ISOLATED_SECRET_RUNNER_WIZARD = false;
14
- const DEFAULT_GROWTH_INTERVAL_MINUTES = 1440;
15
+ const DEFAULT_GROWTH_INTERVAL_MINUTES = 90;
15
16
  const DEFAULT_CONNECTOR_HEALTH_INTERVAL_MINUTES = 360;
16
- const CONNECTOR_KEYS = ['analytics', 'github', 'revenuecat', 'sentry', 'asc'];
17
+ const DEFAULT_SCHEDULER_PROOF_PATH = 'data/openclaw-growth-engineer/runtime/scheduler-proof.jsonl';
18
+ const GROWTH_ENGINEER_PACKAGE_SPEC = process.env.OPENCLAW_GROWTH_ENGINEER_PACKAGE || '@analyticscli/growth-engineer@preview';
19
+ const RUNTIME_DIR = path.dirname(fileURLToPath(import.meta.url));
20
+ const ACCOUNT_SIGNAL_CONNECTOR_KEYS = [
21
+ 'stripe',
22
+ 'lemonsqueezy',
23
+ 'adapty',
24
+ 'superwall',
25
+ 'google-play',
26
+ 'datadog',
27
+ 'bugsnag',
28
+ 'intercom',
29
+ 'zendesk',
30
+ 'apple-search-ads',
31
+ 'google-ads',
32
+ 'meta-ads',
33
+ 'tiktok-ads',
34
+ 'vercel',
35
+ 'cloudflare',
36
+ 'resend',
37
+ 'customerio',
38
+ 'mailchimp',
39
+ 'appfollow',
40
+ 'apptweak',
41
+ 'linear',
42
+ 'postiz',
43
+ ];
44
+ const CONNECTOR_KEYS = [
45
+ 'analytics',
46
+ 'github',
47
+ 'revenuecat',
48
+ 'paddle',
49
+ 'seo',
50
+ 'sentry',
51
+ 'coolify',
52
+ 'asc',
53
+ ...ACCOUNT_SIGNAL_CONNECTOR_KEYS,
54
+ ];
17
55
  class WizardAbortError extends Error {
18
56
  exitCode;
19
57
  constructor(message, exitCode = 130) {
@@ -41,69 +79,640 @@ const CONNECTOR_DEFINITIONS = [
41
79
  summary: 'Read subscription, product, entitlement, and revenue context.',
42
80
  needs: 'A RevenueCat v2 secret API key with read-only project permissions.',
43
81
  },
82
+ {
83
+ key: 'paddle',
84
+ label: 'Paddle Billing metrics',
85
+ summary: 'Read web checkout, revenue, MRR, refunds, chargebacks, and active subscriber metrics.',
86
+ needs: 'A Paddle API key with metrics.read permission for the live account.',
87
+ },
88
+ {
89
+ key: 'seo',
90
+ label: 'SEO / GSC / DataForSEO',
91
+ summary: 'Read organic search demand, GSC clicks/impressions/CTR/position, and optional capped DataForSEO keyword ideas.',
92
+ needs: 'A GSC property plus an access token/service-account credential. DataForSEO credentials are optional and paid.',
93
+ },
44
94
  {
45
95
  key: 'sentry',
46
96
  label: 'Sentry-compatible crash monitoring',
47
97
  summary: 'Read unresolved crashes, regressions, affected users, releases, and production stability signals.',
48
98
  needs: 'A Sentry or GlitchTip-compatible auth token plus the org slug. Project scope is inferred later from app context or config.',
49
99
  },
100
+ {
101
+ key: 'coolify',
102
+ label: 'Coolify deployment monitoring',
103
+ summary: 'Read applications, deployments, servers, resources, and production health-check gaps.',
104
+ needs: 'A Coolify API token with read-only permissions from Keys & Tokens / API tokens.',
105
+ },
50
106
  {
51
107
  key: 'asc',
52
108
  label: 'ASC / App Store Connect CLI',
53
109
  summary: 'Read App Store analytics, reviews/ratings, builds/TestFlight/release context, subscriptions, purchases, and crash totals.',
54
110
  needs: 'ASC_KEY_ID, ASC_ISSUER_ID, and the AuthKey_XXXX.p8 content or path.',
55
111
  },
112
+ {
113
+ key: 'stripe',
114
+ label: 'Stripe billing and checkout',
115
+ summary: 'Read web payments, subscriptions, trials, invoices, refunds, disputes, coupons, and checkout conversion context.',
116
+ needs: 'An account-level Stripe restricted key or secret key with read access to customers, subscriptions, invoices, balance, charges, disputes, prices, products, coupons, and checkout sessions.',
117
+ },
118
+ {
119
+ key: 'lemonsqueezy',
120
+ label: 'Lemon Squeezy sales and licensing',
121
+ summary: 'Read stores, products, variants, orders, subscriptions, discounts, license keys, and churn/revenue context.',
122
+ needs: 'A live-mode Lemon Squeezy API key from account settings.',
123
+ },
124
+ {
125
+ key: 'adapty',
126
+ label: 'Adapty subscriptions and paywalls',
127
+ summary: 'Read mobile subscription, paywall, product, profile, attribution, and revenue signals across Adapty apps.',
128
+ needs: 'An Adapty server-side API key from dashboard app settings. App/project scope is left unpinned.',
129
+ },
130
+ {
131
+ key: 'superwall',
132
+ label: 'Superwall paywall experiments',
133
+ summary: 'Read paywalls, products, placements/campaigns, experiments, subscription outcomes, and conversion evidence.',
134
+ needs: 'A Superwall organization API key with read scopes.',
135
+ },
136
+ {
137
+ key: 'google-play',
138
+ label: 'Google Play Console',
139
+ summary: 'Read Android store, release, review, subscription, in-app purchase, and order signals across accessible apps.',
140
+ needs: 'A Play Console service account JSON credential with account-level read/reporting access.',
141
+ },
142
+ {
143
+ key: 'datadog',
144
+ label: 'Datadog observability',
145
+ summary: 'Read RUM, logs, errors, APM, monitors, incidents, deployment, and reliability signals.',
146
+ needs: 'Datadog API and application keys plus the Datadog site.',
147
+ },
148
+ {
149
+ key: 'bugsnag',
150
+ label: 'Bugsnag crash monitoring',
151
+ summary: 'Read error, release, session, stability, and affected-user signals across visible projects.',
152
+ needs: 'A Bugsnag data-access auth token with read access.',
153
+ },
154
+ {
155
+ key: 'intercom',
156
+ label: 'Intercom support and feedback',
157
+ summary: 'Read conversations, tickets, contacts, companies, support themes, and onboarding friction signals.',
158
+ needs: 'An Intercom private app access token for the workspace.',
159
+ },
160
+ {
161
+ key: 'zendesk',
162
+ label: 'Zendesk support and feedback',
163
+ summary: 'Read support tickets, tags, CSAT, customer friction, cancellation themes, and help-center signals.',
164
+ needs: 'Zendesk subdomain, agent/admin email, and API token or OAuth token.',
165
+ },
166
+ {
167
+ key: 'apple-search-ads',
168
+ label: 'Apple Search Ads (experimental)',
169
+ summary: 'Read iOS paid search campaigns, spend, installs, taps, CPT/CPA, keywords, and campaign quality signals.',
170
+ needs: 'Apple Ads OAuth client credentials or a current access/refresh token with account-level reporting access.',
171
+ experimental: true,
172
+ },
173
+ {
174
+ key: 'google-ads',
175
+ label: 'Google Ads (experimental)',
176
+ summary: 'Read paid search/app campaign spend, clicks, conversions, CAC, ROAS, and landing-page/ad-group signals.',
177
+ needs: 'Google Ads developer token plus OAuth client/refresh token credentials with account-wide read access.',
178
+ experimental: true,
179
+ },
180
+ {
181
+ key: 'meta-ads',
182
+ label: 'Meta Ads (experimental)',
183
+ summary: 'Read Facebook/Instagram campaign, ad set, creative, spend, conversion, CAC, and ROAS signals.',
184
+ needs: 'A Meta access token with Marketing API read permissions for the ad accounts you want analyzed.',
185
+ experimental: true,
186
+ },
187
+ {
188
+ key: 'tiktok-ads',
189
+ label: 'TikTok Ads (experimental)',
190
+ summary: 'Read TikTok campaign, ad group, creative, spend, conversion, CAC, and ROAS signals.',
191
+ needs: 'TikTok Business API app credentials and access token with advertiser reporting access.',
192
+ experimental: true,
193
+ },
194
+ {
195
+ key: 'vercel',
196
+ label: 'Vercel deployments (experimental)',
197
+ summary: 'Read projects, deployments, build failures, domains, environment health, and frontend reliability signals.',
198
+ needs: 'A Vercel access token with read access across the team/account.',
199
+ experimental: true,
200
+ },
201
+ {
202
+ key: 'cloudflare',
203
+ label: 'Cloudflare traffic and edge (experimental)',
204
+ summary: 'Read zones/accounts, traffic, cache, Workers/Pages, WAF/security, DNS, and edge reliability signals.',
205
+ needs: 'A Cloudflare API token with account/zone read scopes for analytics, Workers/Pages, DNS, and security events as needed.',
206
+ experimental: true,
207
+ },
208
+ {
209
+ key: 'resend',
210
+ label: 'Resend lifecycle email (experimental)',
211
+ summary: 'Read domains, broadcasts, transactional email volume, bounces, complaints, and deliverability signals.',
212
+ needs: 'A Resend API key with account-wide read access where available.',
213
+ experimental: true,
214
+ },
215
+ {
216
+ key: 'customerio',
217
+ label: 'Customer.io lifecycle messaging (experimental)',
218
+ summary: 'Read campaigns, broadcasts, journeys, segments, deliveries, conversions, and lifecycle engagement signals.',
219
+ needs: 'Customer.io App API credentials for the workspace.',
220
+ experimental: true,
221
+ },
222
+ {
223
+ key: 'mailchimp',
224
+ label: 'Mailchimp lifecycle email (experimental)',
225
+ summary: 'Read audiences, campaigns, automations, ecommerce, unsubscribes, bounces, and lifecycle performance signals.',
226
+ needs: 'A Mailchimp Marketing API key for the account.',
227
+ experimental: true,
228
+ },
229
+ {
230
+ key: 'appfollow',
231
+ label: 'AppFollow reviews and ASO (experimental)',
232
+ summary: 'Read app reviews, ratings, semantic review themes, ASO positions, and competitor/app collection signals.',
233
+ needs: 'An AppFollow API token from the API Dashboard with account/app collection read access.',
234
+ experimental: true,
235
+ },
236
+ {
237
+ key: 'apptweak',
238
+ label: 'AppTweak ASO intelligence (experimental)',
239
+ summary: 'Read keyword rankings, ASO metadata, competitor movement, category ranks, and store visibility signals.',
240
+ needs: 'An AppTweak API token from an account with API access.',
241
+ experimental: true,
242
+ },
243
+ {
244
+ key: 'linear',
245
+ label: 'Linear planning context (experimental)',
246
+ summary: 'Read teams, projects, issues, cycles, labels, roadmap context, and delivery bottleneck signals.',
247
+ needs: 'A Linear personal API key or OAuth access token with read access across the workspace.',
248
+ experimental: true,
249
+ },
250
+ {
251
+ key: 'postiz',
252
+ label: 'Postiz social publishing (experimental)',
253
+ summary: 'Read social integrations, scheduled/published posts, platform analytics, posting cadence, and content distribution signals.',
254
+ needs: 'A Postiz Public API key from Settings -> Developers -> Public API. Self-hosted installs can also set a custom API base URL.',
255
+ experimental: true,
256
+ },
257
+ ];
258
+ const ACCOUNT_SIGNAL_CONNECTOR_DEFINITIONS = [
259
+ {
260
+ ...CONNECTOR_DEFINITIONS.find((connector) => connector.key === 'stripe'),
261
+ key: 'stripe',
262
+ service: 'stripe',
263
+ docsUrl: 'https://docs.stripe.com/keys',
264
+ sourceKind: 'revenue',
265
+ signalHint: 'Stripe account summary with payments, subscriptions, trials, invoices, refunds, disputes, coupons, checkout sessions, product/price changes, and churn/revenue deltas. Do not pin a single product or price unless the agent explicitly narrows a later run.',
266
+ steps: [
267
+ 'Open Stripe Dashboard -> Developers -> API keys.',
268
+ 'Create a restricted key for OpenClaw/Growth Engineer, or use a standard secret key only when restricted keys are not practical.',
269
+ 'Prefer live-mode read permissions for Customers, Subscriptions, Invoices, Charges, Balance, Disputes, Prices, Products, Coupons, Promotion Codes, and Checkout Sessions.',
270
+ 'Do not select a single product, price, or connected account in the wizard. Account-wide access lets the agent discover the relevant products later.',
271
+ 'Copy the key once and paste it into this local terminal.',
272
+ ],
273
+ credentials: [{ env: 'STRIPE_API_KEY', prompt: 'Paste STRIPE_API_KEY into this local terminal' }],
274
+ },
275
+ {
276
+ ...CONNECTOR_DEFINITIONS.find((connector) => connector.key === 'lemonsqueezy'),
277
+ key: 'lemonsqueezy',
278
+ service: 'lemonsqueezy',
279
+ docsUrl: 'https://docs.lemonsqueezy.com/guides/developer-guide/getting-started',
280
+ sourceKind: 'revenue',
281
+ signalHint: 'Lemon Squeezy account summary with stores, products, variants, orders, subscriptions, discounts, license keys, refunds, and revenue/churn movement. Keep store/product filtering out of setup so the agent can inspect all accessible stores.',
282
+ steps: [
283
+ 'Open Lemon Squeezy Dashboard -> Settings -> API.',
284
+ 'Create a new API key in live mode for production revenue evidence.',
285
+ 'Keep the key private and store it only in this local wizard.',
286
+ 'Do not enter a store ID or product ID here; the agent should discover accessible stores from the account.',
287
+ 'Copy the key and paste it below.',
288
+ ],
289
+ credentials: [{ env: 'LEMON_SQUEEZY_API_KEY', prompt: 'Paste LEMON_SQUEEZY_API_KEY into this local terminal' }],
290
+ },
291
+ {
292
+ ...CONNECTOR_DEFINITIONS.find((connector) => connector.key === 'adapty'),
293
+ key: 'adapty',
294
+ service: 'adapty',
295
+ docsUrl: 'https://adapty.io/docs/api-adapty',
296
+ sourceKind: 'revenue',
297
+ signalHint: 'Adapty summary with apps, paywalls, placements, products, profiles, access levels, subscriptions, attribution, conversion, renewals, cancellations, and revenue signals. Leave app scope unpinned.',
298
+ steps: [
299
+ 'Open Adapty Dashboard.',
300
+ 'Go to App Settings -> General -> API keys.',
301
+ 'Copy the server-side API key for read access.',
302
+ 'Do not paste an app ID, product ID, or paywall ID here; the agent can discover visible apps/paywalls later.',
303
+ 'Paste the API key into this local terminal.',
304
+ ],
305
+ credentials: [{ env: 'ADAPTY_API_KEY', prompt: 'Paste ADAPTY_API_KEY into this local terminal' }],
306
+ },
307
+ {
308
+ ...CONNECTOR_DEFINITIONS.find((connector) => connector.key === 'superwall'),
309
+ key: 'superwall',
310
+ service: 'superwall',
311
+ docsUrl: 'https://api.superwall.com/docs',
312
+ sourceKind: 'revenue',
313
+ signalHint: 'Superwall organization summary with paywalls, placements, campaigns, products, experiments, subscription outcomes, conversion movement, and pricing/package signals. Keep project/paywall scope discoverable.',
314
+ steps: [
315
+ 'Open Superwall dashboard.',
316
+ 'Create or copy an organization API key with read scopes.',
317
+ 'Use organization-wide access so OpenClaw/Hermes can inspect all relevant paywalls and experiments.',
318
+ 'Do not enter a paywall, campaign, placement, or product ID in setup.',
319
+ 'Paste the organization API key below.',
320
+ ],
321
+ credentials: [{ env: 'SUPERWALL_API_KEY', prompt: 'Paste SUPERWALL_API_KEY into this local terminal' }],
322
+ },
323
+ {
324
+ ...CONNECTOR_DEFINITIONS.find((connector) => connector.key === 'google-play'),
325
+ key: 'google-play',
326
+ service: 'google-play',
327
+ docsUrl: 'https://developer.android.com/google/play/developer-api',
328
+ sourceKind: 'store',
329
+ signalHint: 'Google Play account summary with accessible apps, releases, reviews, ratings, Android vitals, subscriptions, in-app products, orders, cancellation reasons, and store/acquisition signals. Do not pin package names during setup.',
330
+ steps: [
331
+ 'Open Play Console -> Setup -> API access.',
332
+ 'Link or create a Google Cloud project and service account if needed.',
333
+ 'Grant read/reporting permissions that cover the apps you want analyzed, including financial/order data only when revenue analysis is desired.',
334
+ 'Save the service-account JSON on this host or paste the JSON into a secret env outside chat.',
335
+ 'Do not enter a package name in this wizard; accessible apps are discovered from the account.',
336
+ ],
337
+ credentials: [
338
+ {
339
+ env: 'GOOGLE_PLAY_SERVICE_ACCOUNT_JSON',
340
+ prompt: 'Paste GOOGLE_PLAY_SERVICE_ACCOUNT_JSON path or JSON content into this local terminal',
341
+ },
342
+ ],
343
+ },
344
+ {
345
+ ...CONNECTOR_DEFINITIONS.find((connector) => connector.key === 'datadog'),
346
+ key: 'datadog',
347
+ service: 'datadog',
348
+ docsUrl: 'https://docs.datadoghq.com/account_management/api-app-keys/',
349
+ sourceKind: 'crash',
350
+ signalHint: 'Datadog account summary with RUM, logs, errors, APM, monitors, incidents, deploy markers, performance regressions, and affected-user reliability signals. Do not pin services during setup.',
351
+ steps: [
352
+ 'Open Datadog -> Organization Settings -> API Keys and Application Keys.',
353
+ 'Create an API key and an application key with read scopes for RUM/logs/APM/monitors/incidents as needed.',
354
+ 'Choose the Datadog site for your account, for example datadoghq.com or datadoghq.eu.',
355
+ 'Do not enter service names, env names, or monitor IDs here; the agent can discover them later.',
356
+ 'Paste the keys below.',
357
+ ],
358
+ credentials: [
359
+ { env: 'DATADOG_API_KEY', prompt: 'Paste DATADOG_API_KEY into this local terminal' },
360
+ { env: 'DATADOG_APP_KEY', prompt: 'Paste DATADOG_APP_KEY into this local terminal' },
361
+ { env: 'DATADOG_SITE', prompt: 'Datadog site', optional: true, defaultValue: process.env.DATADOG_SITE || 'datadoghq.com' },
362
+ ],
363
+ },
364
+ {
365
+ ...CONNECTOR_DEFINITIONS.find((connector) => connector.key === 'bugsnag'),
366
+ key: 'bugsnag',
367
+ service: 'bugsnag',
368
+ docsUrl: 'https://docs.bugsnag.com/api/',
369
+ sourceKind: 'crash',
370
+ signalHint: 'Bugsnag account summary with organizations, projects, errors, releases, stability score, sessions, affected users, and crash/regression signals. Project scope stays discoverable.',
371
+ steps: [
372
+ 'Open Bugsnag settings and create or copy a data-access auth token.',
373
+ 'Grant read access for organizations/projects you want analyzed.',
374
+ 'Do not enter a project ID in this wizard.',
375
+ 'Paste the token below.',
376
+ ],
377
+ credentials: [{ env: 'BUGSNAG_AUTH_TOKEN', prompt: 'Paste BUGSNAG_AUTH_TOKEN into this local terminal' }],
378
+ },
379
+ {
380
+ ...CONNECTOR_DEFINITIONS.find((connector) => connector.key === 'intercom'),
381
+ key: 'intercom',
382
+ service: 'intercom',
383
+ docsUrl: 'https://developers.intercom.com/building-apps/docs/authentication',
384
+ sourceKind: 'feedback',
385
+ signalHint: 'Intercom workspace summary with conversations, tickets, contacts, companies, tags, support themes, onboarding friction, cancellation language, and customer feedback loops.',
386
+ steps: [
387
+ 'Open Intercom Developer Hub and create or open a private app for your own workspace.',
388
+ 'Go to Configure -> Authentication.',
389
+ 'Copy the access token for that workspace.',
390
+ 'Do not enter workspace-specific filters, tags, or inbox IDs here; the agent can discover relevant support surfaces later.',
391
+ 'Paste the token below.',
392
+ ],
393
+ credentials: [{ env: 'INTERCOM_ACCESS_TOKEN', prompt: 'Paste INTERCOM_ACCESS_TOKEN into this local terminal' }],
394
+ },
395
+ {
396
+ ...CONNECTOR_DEFINITIONS.find((connector) => connector.key === 'zendesk'),
397
+ key: 'zendesk',
398
+ service: 'zendesk',
399
+ docsUrl: 'https://developer.zendesk.com/api-reference/introduction/security-and-auth/',
400
+ sourceKind: 'feedback',
401
+ signalHint: 'Zendesk account summary with tickets, tags, views, CSAT, help-center signals, support themes, cancellation/friction language, and customer feedback loops.',
402
+ steps: [
403
+ 'Open Zendesk Admin Center -> Apps and integrations -> APIs -> Zendesk API.',
404
+ 'Create or copy an API token, or use an OAuth token if that is your workspace standard.',
405
+ 'Use the account subdomain and an agent/admin email that can read support data.',
406
+ 'Do not enter view IDs, brand IDs, product IDs, or ticket tags in setup.',
407
+ 'Paste the account credentials below.',
408
+ ],
409
+ credentials: [
410
+ { env: 'ZENDESK_SUBDOMAIN', prompt: 'Zendesk subdomain, for example mycompany', defaultValue: process.env.ZENDESK_SUBDOMAIN || '' },
411
+ { env: 'ZENDESK_EMAIL', prompt: 'Zendesk agent/admin email', defaultValue: process.env.ZENDESK_EMAIL || '' },
412
+ { env: 'ZENDESK_API_TOKEN', prompt: 'Paste ZENDESK_API_TOKEN into this local terminal' },
413
+ ],
414
+ },
415
+ {
416
+ ...CONNECTOR_DEFINITIONS.find((connector) => connector.key === 'apple-search-ads'),
417
+ key: 'apple-search-ads',
418
+ service: 'apple-search-ads',
419
+ docsUrl: 'https://developer.apple.com/documentation/apple_ads/implementing-oauth-for-the-apple-search-ads-api',
420
+ sourceKind: 'acquisition',
421
+ signalHint: 'Experimental Apple Search Ads account summary with organizations, campaigns, ad groups, keywords, spend, taps, installs, CPT/CPA, ROAS, and iOS paid-search acquisition quality. Keep campaign/app IDs out of setup so the agent can discover accessible accounts later.',
422
+ steps: [
423
+ 'Open Apple Search Ads / Apple Ads API access for the account.',
424
+ 'Create OAuth credentials or copy an existing refresh/access token from your server-side integration.',
425
+ 'Use account-level reporting access for every organization you want analyzed.',
426
+ 'Do not enter campaign IDs, ad group IDs, keyword IDs, or app IDs here.',
427
+ 'Paste the account credentials below.',
428
+ ],
429
+ credentials: [
430
+ { env: 'APPLE_SEARCH_ADS_CLIENT_ID', prompt: 'Paste APPLE_SEARCH_ADS_CLIENT_ID, or leave empty if using a token only', optional: true },
431
+ { env: 'APPLE_SEARCH_ADS_CLIENT_SECRET', prompt: 'Paste APPLE_SEARCH_ADS_CLIENT_SECRET, or leave empty if using a token only', optional: true },
432
+ { env: 'APPLE_SEARCH_ADS_REFRESH_TOKEN', prompt: 'Paste APPLE_SEARCH_ADS_REFRESH_TOKEN or current access token into this local terminal' },
433
+ ],
434
+ },
435
+ {
436
+ ...CONNECTOR_DEFINITIONS.find((connector) => connector.key === 'google-ads'),
437
+ key: 'google-ads',
438
+ service: 'google-ads',
439
+ docsUrl: 'https://developers.google.com/google-ads/api/docs/oauth/overview',
440
+ sourceKind: 'acquisition',
441
+ signalHint: 'Experimental Google Ads account summary with accessible customer accounts, campaigns, ad groups, keywords, spend, clicks, conversions, ROAS, CAC, search terms, and app/web acquisition quality. Keep customer IDs and campaign IDs discoverable.',
442
+ steps: [
443
+ 'Open Google Ads API Center and Google Cloud OAuth credentials for the account.',
444
+ 'Create or copy a developer token plus OAuth client credentials and refresh token.',
445
+ 'Use a manager/account credential that can read every customer account you want analyzed.',
446
+ 'Do not enter customer IDs, campaign IDs, ad group IDs, or conversion action IDs in setup.',
447
+ 'Paste the account-wide credentials below.',
448
+ ],
449
+ credentials: [
450
+ { env: 'GOOGLE_ADS_DEVELOPER_TOKEN', prompt: 'Paste GOOGLE_ADS_DEVELOPER_TOKEN into this local terminal' },
451
+ { env: 'GOOGLE_ADS_CLIENT_ID', prompt: 'Paste GOOGLE_ADS_CLIENT_ID into this local terminal' },
452
+ { env: 'GOOGLE_ADS_CLIENT_SECRET', prompt: 'Paste GOOGLE_ADS_CLIENT_SECRET into this local terminal' },
453
+ { env: 'GOOGLE_ADS_REFRESH_TOKEN', prompt: 'Paste GOOGLE_ADS_REFRESH_TOKEN into this local terminal' },
454
+ { env: 'GOOGLE_ADS_LOGIN_CUSTOMER_ID', prompt: 'Optional manager login customer ID (empty = discover accessible accounts)', optional: true },
455
+ ],
456
+ },
457
+ {
458
+ ...CONNECTOR_DEFINITIONS.find((connector) => connector.key === 'meta-ads'),
459
+ key: 'meta-ads',
460
+ service: 'meta-ads',
461
+ docsUrl: 'https://developers.facebook.com/docs/marketing-apis/',
462
+ sourceKind: 'acquisition',
463
+ signalHint: 'Experimental Meta Ads account summary with accessible ad accounts, campaigns, ad sets, creatives, spend, impressions, clicks, conversion values, CAC, ROAS, and paid social acquisition quality. Keep ad account IDs and campaign IDs discoverable.',
464
+ steps: [
465
+ 'Open Meta for Developers / Business Manager for the app or system user that owns Marketing API access.',
466
+ 'Create or copy a long-lived access token with ads_read and related read permissions approved for your business.',
467
+ 'Use business/account-level access for all ad accounts you want analyzed.',
468
+ 'Do not enter ad account IDs, campaign IDs, pixel IDs, or page IDs here.',
469
+ 'Paste the access token below.',
470
+ ],
471
+ credentials: [{ env: 'META_ADS_ACCESS_TOKEN', prompt: 'Paste META_ADS_ACCESS_TOKEN into this local terminal' }],
472
+ },
473
+ {
474
+ ...CONNECTOR_DEFINITIONS.find((connector) => connector.key === 'tiktok-ads'),
475
+ key: 'tiktok-ads',
476
+ service: 'tiktok-ads',
477
+ docsUrl: 'https://business-api.tiktok.com/portal/docs',
478
+ sourceKind: 'acquisition',
479
+ signalHint: 'Experimental TikTok Ads account summary with advertisers, campaigns, ad groups, creatives, spend, impressions, clicks, conversions, CAC, ROAS, and paid social acquisition quality. Keep advertiser/campaign IDs out of setup.',
480
+ steps: [
481
+ 'Open TikTok Business API / Marketing API portal.',
482
+ 'Create or copy app credentials and an access token with advertiser reporting permissions.',
483
+ 'Use a credential that can list the advertisers you want analyzed.',
484
+ 'Do not enter advertiser IDs, campaign IDs, ad group IDs, or creative IDs in setup.',
485
+ 'Paste the credentials below.',
486
+ ],
487
+ credentials: [
488
+ { env: 'TIKTOK_ADS_ACCESS_TOKEN', prompt: 'Paste TIKTOK_ADS_ACCESS_TOKEN into this local terminal' },
489
+ { env: 'TIKTOK_ADS_APP_ID', prompt: 'Paste TIKTOK_ADS_APP_ID, or leave empty if token-only', optional: true },
490
+ { env: 'TIKTOK_ADS_SECRET', prompt: 'Paste TIKTOK_ADS_SECRET, or leave empty if token-only', optional: true },
491
+ ],
492
+ },
493
+ {
494
+ ...CONNECTOR_DEFINITIONS.find((connector) => connector.key === 'vercel'),
495
+ key: 'vercel',
496
+ service: 'vercel',
497
+ docsUrl: 'https://vercel.com/docs/rest-api/reference/welcome',
498
+ sourceKind: 'infrastructure',
499
+ signalHint: 'Experimental Vercel account summary with projects, deployments, failed builds, domains, edge/runtime errors, environment health, web vitals where available, and release reliability. Keep project IDs/team IDs discoverable.',
500
+ steps: [
501
+ 'Open Vercel Account Settings -> Tokens.',
502
+ 'Create an access token with read access for the team/account you want analyzed.',
503
+ 'Use team/account-level access so the agent can discover projects and deployments.',
504
+ 'Do not enter project IDs, deployment IDs, domain names, or team IDs in setup.',
505
+ 'Paste the token below.',
506
+ ],
507
+ credentials: [{ env: 'VERCEL_ACCESS_TOKEN', prompt: 'Paste VERCEL_ACCESS_TOKEN into this local terminal' }],
508
+ },
509
+ {
510
+ ...CONNECTOR_DEFINITIONS.find((connector) => connector.key === 'cloudflare'),
511
+ key: 'cloudflare',
512
+ service: 'cloudflare',
513
+ docsUrl: 'https://developers.cloudflare.com/fundamentals/api/get-started/create-token/',
514
+ sourceKind: 'infrastructure',
515
+ signalHint: 'Experimental Cloudflare account summary with accounts, zones, Workers, Pages, DNS, traffic, cache, WAF/security events, outages, and edge reliability. Keep account/zone IDs discoverable.',
516
+ steps: [
517
+ 'Open Cloudflare dashboard -> My Profile -> API Tokens.',
518
+ 'Create a custom token with read scopes for accounts/zones, analytics, Workers/Pages, DNS, and security events as needed.',
519
+ 'Prefer account-level read access for every zone/app the agent may analyze.',
520
+ 'Do not enter account IDs, zone IDs, Worker names, or domain names here.',
521
+ 'Paste the API token below.',
522
+ ],
523
+ credentials: [{ env: 'CLOUDFLARE_API_TOKEN', prompt: 'Paste CLOUDFLARE_API_TOKEN into this local terminal' }],
524
+ },
525
+ {
526
+ ...CONNECTOR_DEFINITIONS.find((connector) => connector.key === 'resend'),
527
+ key: 'resend',
528
+ service: 'resend',
529
+ docsUrl: 'https://resend.com/docs/dashboard/api-keys/introduction',
530
+ sourceKind: 'lifecycle',
531
+ signalHint: 'Experimental Resend account summary with domains, broadcasts, transactional volume, bounces, complaints, delivery health, and lifecycle/email deliverability signals. Keep domain filters discoverable.',
532
+ steps: [
533
+ 'Open Resend Dashboard -> API Keys.',
534
+ 'Create an API key with the narrowest account-wide access available for reporting.',
535
+ 'Use account-level access so the agent can inspect all relevant domains and sending streams.',
536
+ 'Do not enter domain IDs, broadcast IDs, or audience/list IDs in setup.',
537
+ 'Paste the key below.',
538
+ ],
539
+ credentials: [{ env: 'RESEND_API_KEY', prompt: 'Paste RESEND_API_KEY into this local terminal' }],
540
+ },
541
+ {
542
+ ...CONNECTOR_DEFINITIONS.find((connector) => connector.key === 'customerio'),
543
+ key: 'customerio',
544
+ service: 'customerio',
545
+ docsUrl: 'https://docs.customer.io/accounts-and-workspaces/managing-credentials/',
546
+ sourceKind: 'lifecycle',
547
+ signalHint: 'Experimental Customer.io account summary with campaigns, broadcasts, journeys, segments, deliveries, conversions, unsubscribes, and lifecycle engagement quality. Keep workspace object IDs discoverable.',
548
+ steps: [
549
+ 'Open Customer.io -> Settings -> Workspace Settings -> API and Webhook Credentials.',
550
+ 'Create or copy App API credentials for the workspace.',
551
+ 'Use workspace-level credentials so the agent can inspect campaigns, broadcasts, journeys, and segments.',
552
+ 'Do not enter campaign IDs, segment IDs, newsletter IDs, or workspace-specific filters here.',
553
+ 'Paste the credentials below.',
554
+ ],
555
+ credentials: [
556
+ { env: 'CUSTOMERIO_APP_API_KEY', prompt: 'Paste CUSTOMERIO_APP_API_KEY into this local terminal' },
557
+ { env: 'CUSTOMERIO_SITE_ID', prompt: 'Paste CUSTOMERIO_SITE_ID, or leave empty when App API key is enough', optional: true },
558
+ ],
559
+ },
560
+ {
561
+ ...CONNECTOR_DEFINITIONS.find((connector) => connector.key === 'mailchimp'),
562
+ key: 'mailchimp',
563
+ service: 'mailchimp',
564
+ docsUrl: 'https://mailchimp.com/developer/marketing/guides/quick-start/',
565
+ sourceKind: 'lifecycle',
566
+ signalHint: 'Experimental Mailchimp account summary with audiences, campaigns, automations, ecommerce, unsubscribes, bounces, clicks, opens, and lifecycle/email performance. Keep audience and campaign IDs discoverable.',
567
+ steps: [
568
+ 'Open Mailchimp -> Account & billing -> Extras -> API keys.',
569
+ 'Create or copy a Marketing API key for the account.',
570
+ 'Use account-level access so the agent can inspect all relevant audiences and campaigns.',
571
+ 'Do not enter audience IDs, campaign IDs, list IDs, or store IDs in setup.',
572
+ 'Paste the API key below.',
573
+ ],
574
+ credentials: [{ env: 'MAILCHIMP_API_KEY', prompt: 'Paste MAILCHIMP_API_KEY into this local terminal' }],
575
+ },
576
+ {
577
+ ...CONNECTOR_DEFINITIONS.find((connector) => connector.key === 'appfollow'),
578
+ key: 'appfollow',
579
+ service: 'appfollow',
580
+ docsUrl: 'https://support.appfollow.io/hc/en-us/articles/4403679243409-API-Access-Methods',
581
+ sourceKind: 'aso',
582
+ signalHint: 'Experimental AppFollow account summary with app collections, reviews, ratings, semantic themes, ASO/rank signals, competitor context, and store feedback quality. Keep collection/app IDs discoverable.',
583
+ steps: [
584
+ 'Open AppFollow -> Integrations -> API Dashboard.',
585
+ 'Create or copy the API token from an Owner/Admin account.',
586
+ 'Use account-level access for every app collection you want analyzed.',
587
+ 'Do not enter app IDs, collection IDs, country codes, or store IDs in setup.',
588
+ 'Paste the API token below.',
589
+ ],
590
+ credentials: [{ env: 'APPFOLLOW_API_TOKEN', prompt: 'Paste APPFOLLOW_API_TOKEN into this local terminal' }],
591
+ },
592
+ {
593
+ ...CONNECTOR_DEFINITIONS.find((connector) => connector.key === 'apptweak'),
594
+ key: 'apptweak',
595
+ service: 'apptweak',
596
+ docsUrl: 'https://help.apptweak.com/en/articles/4901806-learn-more-about-apptweak-takeout-api',
597
+ sourceKind: 'aso',
598
+ signalHint: 'Experimental AppTweak account summary with keyword rankings, category ranks, metadata, ASO visibility, competitors, and store-market opportunity signals. Keep app IDs and markets discoverable.',
599
+ steps: [
600
+ 'Open AppTweak account/API settings or API documentation area.',
601
+ 'Create or copy an API token from an account with API access.',
602
+ 'Use account-level API access for every app/market you want analyzed.',
603
+ 'Do not enter app IDs, competitor IDs, country codes, keyword IDs, or store IDs here.',
604
+ 'Paste the API token below.',
605
+ ],
606
+ credentials: [{ env: 'APPTWEAK_API_TOKEN', prompt: 'Paste APPTWEAK_API_TOKEN into this local terminal' }],
607
+ },
608
+ {
609
+ ...CONNECTOR_DEFINITIONS.find((connector) => connector.key === 'linear'),
610
+ key: 'linear',
611
+ service: 'linear',
612
+ docsUrl: 'https://linear.app/developers',
613
+ sourceKind: 'planning',
614
+ signalHint: 'Experimental Linear workspace summary with teams, projects, cycles, issues, labels, stale work, roadmap commitments, delivery bottlenecks, and product execution context. Keep team/project IDs discoverable.',
615
+ steps: [
616
+ 'Open Linear -> Settings -> API.',
617
+ 'Create a personal API key or use an OAuth token with read access.',
618
+ 'Use workspace-level read access so the agent can inspect all relevant teams/projects.',
619
+ 'Do not enter team IDs, project IDs, issue IDs, labels, or cycle IDs in setup.',
620
+ 'Paste the token below.',
621
+ ],
622
+ credentials: [{ env: 'LINEAR_API_KEY', prompt: 'Paste LINEAR_API_KEY or LINEAR_ACCESS_TOKEN into this local terminal' }],
623
+ },
624
+ {
625
+ ...CONNECTOR_DEFINITIONS.find((connector) => connector.key === 'postiz'),
626
+ key: 'postiz',
627
+ service: 'postiz',
628
+ docsUrl: 'https://docs.postiz.com/public-api',
629
+ sourceKind: 'acquisition',
630
+ signalHint: 'Experimental Postiz account summary with connected social integrations, scheduled/published posts, platform analytics, content cadence, failed/pending posts, and organic distribution signals. Keep integration IDs and channel filters discoverable.',
631
+ steps: [
632
+ 'Open Postiz -> Settings -> Developers -> Public API.',
633
+ 'Create or copy a Public API key. OAuth2 tokens are also usable for app-user flows.',
634
+ 'For Postiz Cloud, keep the default API base URL. For self-hosted Postiz, use your backend public URL ending in /public/v1.',
635
+ 'Do not enter integration IDs, channel IDs, platform names, post IDs, or tag filters in setup.',
636
+ 'Paste the API credentials below.',
637
+ ],
638
+ credentials: [
639
+ { env: 'POSTIZ_API_KEY', prompt: 'Paste POSTIZ_API_KEY into this local terminal' },
640
+ {
641
+ env: 'POSTIZ_API_BASE_URL',
642
+ prompt: 'Postiz API base URL',
643
+ optional: true,
644
+ defaultValue: process.env.POSTIZ_API_BASE_URL || 'https://api.postiz.com/public/v1',
645
+ },
646
+ ],
647
+ },
56
648
  ];
649
+ const ACCOUNT_SIGNAL_CONNECTORS = new Map(ACCOUNT_SIGNAL_CONNECTOR_DEFINITIONS.map((definition) => [definition.key, definition]));
650
+ function getAccountSignalConnectorDefinition(key) {
651
+ return ACCOUNT_SIGNAL_CONNECTORS.get(key) || null;
652
+ }
653
+ function isAccountSignalConnector(key) {
654
+ return ACCOUNT_SIGNAL_CONNECTORS.has(key);
655
+ }
57
656
  const DEFAULT_CADENCE_PLAN = [
657
+ {
658
+ key: 'healthcheck',
659
+ title: '90-minute production error healthcheck',
660
+ intervalMinutes: 90,
661
+ criticalOnly: true,
662
+ focusAreas: ['crash', 'deployment', 'availability'],
663
+ sourcePriorities: ['sentry', 'glitchtip', 'coolify', 'asc_cli'],
664
+ objective: 'Check Sentry/GlitchTip and Coolify for production errors, failed deploys, unhealthy resources, and availability blockers across every configured app.',
665
+ instructions: 'For Sentry/GlitchTip app errors, compare the issue release or app version with ASC production versions first. Ignore errors that only affect TestFlight, debug, staging, unreleased, or non-production app versions. Keep the social output short and action-oriented.',
666
+ },
58
667
  {
59
668
  key: 'daily',
60
- title: 'Daily Sentry and production guardrail',
669
+ title: 'Daily behavioral anomaly guardrail',
61
670
  intervalDays: 1,
62
671
  criticalOnly: true,
63
- focusAreas: ['sentry_errors', 'crash', 'onboarding', 'conversion', 'paywall', 'purchase'],
64
- sourcePriorities: ['sentry', 'glitchtip', 'analytics', 'revenuecat', 'asc_cli', 'feedback', 'github'],
65
- objective: 'Analyze every configured project for critical production blockers: Sentry/GlitchTip errors, crashes, onboarding or purchase drop-offs, zero-conversion days, missing buyers, very low users, and other silent business anomalies.',
66
- instructions: 'Compare against recent baselines across connected sources and code changes. If the finding is critical, produce the exact fix or next debugging step and prefer a GitHub issue or draft PR when GitHub write access is configured; otherwise hand off via OpenClaw chat. Avoid generic growth ideas.',
672
+ focusAreas: ['analytics_anomaly', 'onboarding', 'conversion', 'paywall', 'purchase', 'retention', 'revenue'],
673
+ sourcePriorities: ['analytics', 'revenuecat', 'paddle', 'asc_cli', 'feedback', 'github', 'sentry', 'glitchtip', 'coolify'],
674
+ objective: 'Detect non-Sentry product and payment anomalies that affect real users: broken login or account flows inferred from behavior, onboarding or purchase drop-offs, zero-conversion days, missing buyers, very low active users, retention cliffs, and revenue anomalies.',
675
+ instructions: 'Compare AnalyticsCLI, RevenueCat, Paddle, ASC, feedback, memory/state, and recent code changes against recent baselines. Use Sentry/GlitchTip/Coolify only as corroborating context; do not repeat pure crash or deployment alerts that belong to the 90-minute healthcheck.',
67
676
  },
68
677
  {
69
678
  key: 'weekly',
70
679
  title: 'Weekly executive product and growth summary',
71
680
  intervalDays: 7,
72
681
  criticalOnly: false,
73
- focusAreas: ['conversion', 'paywall', 'onboarding', 'marketing', 'retention', 'stability'],
74
- sourcePriorities: ['analytics', 'revenuecat', 'asc_cli', 'feedback', 'sentry', 'github'],
75
- objective: 'Create an executive summary across all configured projects, connectors, recent releases, code changes, revenue, activation, retention, reviews, and production stability.',
76
- instructions: 'Pick one to three high-confidence improvements with evidence, expected KPI movement, likely code/store surfaces, owner-ready next steps, and a verification plan. Create GitHub issues or draft PR proposals only when the evidence is specific enough.',
682
+ focusAreas: ['conversion', 'paywall', 'onboarding', 'marketing', 'retention', 'stability', 'seo'],
683
+ sourcePriorities: ['analytics', 'revenuecat', 'paddle', 'seo', 'asc_cli', 'feedback', 'sentry', 'coolify', 'github'],
684
+ objective: 'Create a deep app-by-app executive summary across all configured projects, connectors, recent releases, code changes, traffic, SEO/acquisition, revenue, activation, conversion, retention, reviews, and production stability.',
685
+ instructions: 'Be detailed. Group findings per app, explain why each recommendation should improve app usage, revenue, conversion, retention, or traffic, include expected KPI movement, likely code/store surfaces, owner-ready next steps, and verification plans. Generate charts when they clarify the evidence.',
77
686
  },
78
687
  {
79
688
  key: 'monthly',
80
689
  title: 'Monthly deep product, business, and code review',
81
690
  intervalDays: 30,
82
691
  criticalOnly: false,
83
- focusAreas: ['conversion', 'paywall', 'retention', 'marketing', 'onboarding', 'codebase'],
84
- sourcePriorities: ['analytics', 'revenuecat', 'asc_cli', 'feedback', 'sentry', 'github'],
85
- objective: 'Compare all configured projects month-over-month: MRR, trial conversion, churn, acquisition quality, store conversion, retention, review themes, feature usage, crash totals, and codebase changes.',
86
- instructions: 'Decide what should be built, changed, deleted, or instrumented next. Tie conclusions to connector data plus codebase evidence and explain why each recommendation should move revenue, activation, retention, stability, or acquisition quality.',
692
+ focusAreas: ['conversion', 'paywall', 'retention', 'marketing', 'onboarding', 'codebase', 'seo'],
693
+ sourcePriorities: ['analytics', 'revenuecat', 'paddle', 'seo', 'asc_cli', 'feedback', 'sentry', 'coolify', 'github'],
694
+ objective: 'Compare all configured projects month-over-month: MRR, trial conversion, churn, Paddle revenue/subscriber movement, SEO demand/clicks, acquisition quality, store conversion, retention, review themes, feature usage, crash totals, and codebase changes.',
695
+ instructions: 'Be very detailed and app-grouped. Decide what should be built, changed, deleted, priced differently, marketed differently, or instrumented next. Tie conclusions to connector data plus codebase evidence and explain why each recommendation should move revenue, conversion, retention, traffic, or acquisition quality. Generate charts when useful.',
87
696
  },
88
697
  {
89
698
  key: 'quarterly',
90
- title: 'Quarterly positioning, pricing, and roadmap review',
699
+ title: '3-month positioning, pricing, and roadmap review',
91
700
  intervalDays: 91,
92
701
  criticalOnly: false,
93
702
  focusAreas: ['marketing', 'paywall', 'retention', 'conversion', 'onboarding'],
94
- sourcePriorities: ['analytics', 'revenuecat', 'asc_cli', 'feedback', 'github', 'sentry'],
95
- objective: 'Revisit positioning, pricing/packaging, onboarding architecture, roadmap assumptions, tracking quality, codebase constraints, and major funnel bets across every configured project.',
96
- instructions: 'Find structural constraints and durable opportunities. Tie recommendations to cohort behavior, monetization, reviews, channel quality, and shipped changes.',
703
+ sourcePriorities: ['analytics', 'revenuecat', 'paddle', 'seo', 'asc_cli', 'feedback', 'github', 'sentry'],
704
+ objective: 'Revisit positioning, pricing/packaging, onboarding architecture, roadmap assumptions, tracking quality, codebase constraints, and major funnel bets across every configured app.',
705
+ instructions: 'Find structural constraints and durable opportunities, not small UI tweaks. Group the analysis by app and tie recommendations to cohort behavior, monetization, SEO demand, reviews, channel quality, and shipped changes. Include concrete roadmap, pricing, conversion, and traffic recommendations.',
97
706
  },
98
707
  {
99
708
  key: 'six_months',
100
709
  title: 'Six-month instrumentation and growth-system audit',
101
710
  intervalDays: 182,
102
711
  criticalOnly: false,
103
- focusAreas: ['retention', 'conversion', 'paywall', 'marketing', 'general'],
104
- sourcePriorities: ['analytics', 'revenuecat', 'asc_cli', 'feedback', 'sentry'],
105
- objective: 'Audit connector coverage, SDK instrumentation, event taxonomy, data reliability, memory, growth loops, and whether product/code strategy still matches the best users across configured projects.',
106
- instructions: 'Prioritize measurement fixes and system changes that make future analysis more trustworthy. Identify stale events, missing attribution, weak identity, and misleading dashboards.',
712
+ focusAreas: ['retention', 'conversion', 'paywall', 'marketing', 'general', 'seo'],
713
+ sourcePriorities: ['analytics', 'revenuecat', 'paddle', 'seo', 'asc_cli', 'feedback', 'sentry'],
714
+ objective: 'Audit connector coverage, SDK instrumentation, event taxonomy, data reliability, memory, growth loops, and whether product/code strategy still matches the best users across configured apps.',
715
+ instructions: 'Group by app. Prioritize measurement fixes and system changes that make future analysis more trustworthy, then identify the highest-leverage app/revenue/conversion/SEO/traffic improvements. Identify stale events, missing attribution, weak identity, broken feedback loops, and misleading dashboards.',
107
716
  },
108
717
  {
109
718
  key: 'yearly',
@@ -111,7 +720,7 @@ const DEFAULT_CADENCE_PLAN = [
111
720
  intervalDays: 365,
112
721
  criticalOnly: false,
113
722
  focusAreas: ['marketing', 'retention', 'paywall', 'conversion', 'general'],
114
- sourcePriorities: ['analytics', 'revenuecat', 'asc_cli', 'feedback', 'sentry'],
723
+ sourcePriorities: ['analytics', 'revenuecat', 'paddle', 'seo', 'asc_cli', 'feedback', 'sentry'],
115
724
  objective: 'Reset strategy from evidence across every configured project: market/channel fit, monetization model, retention ceiling, product scope, and whether to double down, reposition, rebuild, or sunset major surfaces/features.',
116
725
  instructions: 'Use the full year of memory, releases, revenue, acquisition, reviews, code changes, and cohort behavior. Produce strategic experiments and stop-doing decisions.',
117
726
  },
@@ -155,13 +764,27 @@ function isTruthyEnv(value) {
155
764
  function isFalseyEnv(value) {
156
765
  return ['0', 'false', 'no', 'n', 'off'].includes(String(value || '').trim().toLowerCase());
157
766
  }
767
+ function resolveDefaultConfigPath() {
768
+ const explicit = String(process.env.OPENCLAW_GROWTH_CONFIG_PATH || '').trim();
769
+ if (explicit)
770
+ return explicit;
771
+ const homeConfigPath = process.env.HOME ? path.join(process.env.HOME, 'data/openclaw-growth-engineer/config.json') : '';
772
+ const homeStatePath = process.env.HOME ? path.join(process.env.HOME, 'data/openclaw-growth-engineer/state.json') : '';
773
+ if (homeConfigPath && existsSync(homeConfigPath) && existsSync(homeStatePath))
774
+ return homeConfigPath;
775
+ if (!existsSync(DEFAULT_CONFIG_PATH) && homeConfigPath && existsSync(homeConfigPath))
776
+ return homeConfigPath;
777
+ return DEFAULT_CONFIG_PATH;
778
+ }
158
779
  function parseArgs(argv) {
780
+ const defaultConfigPath = resolveDefaultConfigPath();
159
781
  const args = {
160
- config: DEFAULT_CONFIG_PATH,
782
+ config: defaultConfigPath,
161
783
  connectorWizard: false,
162
784
  connectors: '',
163
785
  noSelfUpdate: false,
164
- out: DEFAULT_CONFIG_PATH,
786
+ out: defaultConfigPath,
787
+ sandboxSmoke: false,
165
788
  };
166
789
  for (let i = 0; i < argv.length; i += 1) {
167
790
  const token = argv[i];
@@ -189,6 +812,10 @@ function parseArgs(argv) {
189
812
  else if (token === '--no-self-update') {
190
813
  args.noSelfUpdate = true;
191
814
  }
815
+ else if (token === '--sandbox-smoke') {
816
+ args.sandboxSmoke = true;
817
+ args.noSelfUpdate = true;
818
+ }
192
819
  else if (token === '--help' || token === '-h') {
193
820
  printHelpAndExit(0);
194
821
  }
@@ -206,10 +833,14 @@ function printHelpAndExit(exitCode, reason = null) {
206
833
  OpenClaw Growth Setup Wizard
207
834
 
208
835
  Usage:
209
- node scripts/openclaw-growth-wizard.mjs [--out <config-path>]
210
- node scripts/openclaw-growth-wizard.mjs --connectors [analytics,github,revenuecat,sentry,asc] [--config <config-path>]
836
+ npx -y @analyticscli/growth-engineer@preview wizard [--out <config-path>]
837
+ npx -y @analyticscli/growth-engineer@preview wizard --connectors [${CONNECTOR_KEYS.join(',')}]
838
+
839
+ Compatibility note:
840
+ Existing cron/heartbeat runners may still execute generated runtime scripts, but user-facing setup and connector repair should use the npx command above.
211
841
 
212
842
  Options:
843
+ --config <file> Override auto-discovered config path
213
844
  --no-self-update Skip the ClawHub skill update check for this run
214
845
  `);
215
846
  process.exit(exitCode);
@@ -220,6 +851,121 @@ function quote(value) {
220
851
  }
221
852
  return `'${String(value).replace(/'/g, `'\\''`)}'`;
222
853
  }
854
+ function resolveRuntimeScriptPath(scriptName) {
855
+ const candidates = [
856
+ path.join(RUNTIME_DIR, scriptName),
857
+ path.resolve('scripts', scriptName),
858
+ path.resolve('skills/openclaw-growth-engineer/scripts', scriptName),
859
+ ];
860
+ return candidates.find((candidate) => existsSync(candidate)) || path.join(RUNTIME_DIR, scriptName);
861
+ }
862
+ function nodeRuntimeScriptCommand(scriptName) {
863
+ return `node ${quote(resolveRuntimeScriptPath(scriptName))}`;
864
+ }
865
+ function growthEngineerPackageCommand(args) {
866
+ return `npx -y ${quote(GROWTH_ENGINEER_PACKAGE_SPEC)} ${args}`;
867
+ }
868
+ function getWizardDefaultSourceCommand(sourceName) {
869
+ const normalized = String(sourceName || '').trim().toLowerCase();
870
+ if (normalized === 'analytics' || normalized === 'analyticscli') {
871
+ return nodeRuntimeScriptCommand('export-analytics-summary.mjs');
872
+ }
873
+ if (normalized === 'revenuecat' || normalized === 'revenue-cat') {
874
+ return nodeRuntimeScriptCommand('export-revenuecat-summary.mjs');
875
+ }
876
+ if (normalized === 'paddle') {
877
+ return nodeRuntimeScriptCommand('export-paddle-summary.mjs');
878
+ }
879
+ if (['seo', 'gsc', 'google-search-console', 'search-console', 'dataforseo'].includes(normalized)) {
880
+ return nodeRuntimeScriptCommand('export-seo-summary.mjs');
881
+ }
882
+ if (normalized === 'sentry' || normalized === 'glitchtip') {
883
+ return nodeRuntimeScriptCommand('export-sentry-summary.mjs');
884
+ }
885
+ if (normalized === 'coolify') {
886
+ return growthEngineerPackageCommand('exporters coolify-summary');
887
+ }
888
+ if (normalized === 'feedback') {
889
+ return getDefaultSourceCommand('feedback');
890
+ }
891
+ if (['asc', 'asc-cli', 'app-store-connect', 'app_store_connect'].includes(normalized)) {
892
+ return nodeRuntimeScriptCommand('export-asc-summary.mjs');
893
+ }
894
+ return getDefaultSourceCommand(sourceName);
895
+ }
896
+ function replaceLegacyRuntimeScriptCommand(command) {
897
+ const trimmed = String(command || '').trim();
898
+ if (!trimmed)
899
+ return trimmed;
900
+ 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-runner\.mjs|openclaw-growth-preflight\.mjs)(?=\s|$)/, (_match, scriptName) => nodeRuntimeScriptCommand(scriptName));
901
+ }
902
+ function sourceCommandNeedsActiveConfig(sourceName, command) {
903
+ const normalized = String(sourceName || '').trim().toLowerCase();
904
+ const value = String(command || '').toLowerCase();
905
+ return (normalized === 'sentry' ||
906
+ normalized === 'glitchtip' ||
907
+ normalized === 'coolify' ||
908
+ value.includes('export-sentry-summary') ||
909
+ value.includes('export-coolify-summary') ||
910
+ value.includes('exporters coolify-summary'));
911
+ }
912
+ function withWizardConfigArg(sourceName, command, configPath) {
913
+ const trimmed = String(command || '').trim();
914
+ if (!trimmed || !configPath || !sourceCommandNeedsActiveConfig(sourceName, trimmed))
915
+ return trimmed;
916
+ return trimmed
917
+ .replace(/(^|\s)--config=(?:"[^"]*"|'[^']*'|\S+)/, `$1--config ${quote(configPath)}`)
918
+ .replace(/(^|\s)--config\s+(?:"[^"]*"|'[^']*'|\S+)/, `$1--config ${quote(configPath)}`)
919
+ .replace(new RegExp(`^(?!.*(?:^|\\s)--config(?:=|\\s|$))(.+)$`), `$1 --config ${quote(configPath)}`)
920
+ .trim();
921
+ }
922
+ function normalizeWizardSourceCommand(sourceName, source, configPath = null) {
923
+ const current = replaceLegacyRuntimeScriptCommand(source?.command || '');
924
+ const command = current || getWizardDefaultSourceCommand(sourceName);
925
+ return withWizardConfigArg(sourceName, command, configPath);
926
+ }
927
+ function migrateRuntimeSourceCommands(config, configPath = null) {
928
+ if (!config || typeof config !== 'object')
929
+ return config;
930
+ const sources = config.sources && typeof config.sources === 'object' ? config.sources : {};
931
+ const nextSources = { ...sources };
932
+ for (const sourceName of ['analytics', 'revenuecat', 'paddle', 'seo', 'sentry', 'coolify']) {
933
+ if (nextSources[sourceName]?.mode === 'command') {
934
+ nextSources[sourceName] = {
935
+ ...nextSources[sourceName],
936
+ command: normalizeWizardSourceCommand(sourceName, nextSources[sourceName], configPath),
937
+ };
938
+ }
939
+ }
940
+ if (Array.isArray(nextSources.extra)) {
941
+ nextSources.extra = nextSources.extra.map((source) => {
942
+ if (!source || source.mode !== 'command')
943
+ return source;
944
+ const service = String(source.service || source.key || '').toLowerCase();
945
+ const sourceName = ['asc', 'asc-cli', 'app-store-connect', 'app_store_connect'].includes(service)
946
+ ? 'asc'
947
+ : service;
948
+ return {
949
+ ...source,
950
+ command: normalizeWizardSourceCommand(sourceName, source, configPath),
951
+ };
952
+ });
953
+ }
954
+ return {
955
+ ...config,
956
+ sources: nextSources,
957
+ };
958
+ }
959
+ async function migrateRuntimeSourceCommandsFile(configPath) {
960
+ const existing = await readJsonIfPresent(configPath).catch(() => null);
961
+ if (!existing || typeof existing !== 'object')
962
+ return null;
963
+ const migrated = migrateRuntimeSourceCommands(existing, configPath);
964
+ if (JSON.stringify(existing.sources || {}) !== JSON.stringify(migrated.sources || {})) {
965
+ await writeJsonFile(configPath, migrated);
966
+ }
967
+ return migrated;
968
+ }
223
969
  function normalizeConnectorKey(value) {
224
970
  const normalized = String(value || '').trim().toLowerCase().replace(/[_\s]+/g, '-');
225
971
  if (!normalized)
@@ -232,10 +978,60 @@ function normalizeConnectorKey(value) {
232
978
  return 'github';
233
979
  if (['revenuecat', 'revenue-cat', 'rc', 'revenuecat-mcp'].includes(normalized))
234
980
  return 'revenuecat';
981
+ if (['paddle', 'paddle-billing', 'billing-metrics', 'web-revenue'].includes(normalized))
982
+ return 'paddle';
983
+ if (['seo', 'gsc', 'google-search-console', 'search-console', 'dataforseo', 'organic-search'].includes(normalized))
984
+ return 'seo';
235
985
  if (['sentry', 'sentry-api', 'sentry-mcp', 'crashes', 'errors', 'crash-reporting'].includes(normalized))
236
986
  return 'sentry';
987
+ if (['coolify', 'coolify-api', 'deployment', 'deployments', 'hosting', 'infra', 'infrastructure'].includes(normalized))
988
+ return 'coolify';
237
989
  if (['asc', 'asc-cli', 'app-store-connect', 'appstoreconnect', 'app-store'].includes(normalized))
238
990
  return 'asc';
991
+ if (['stripe', 'stripe-billing', 'stripe-payments'].includes(normalized))
992
+ return 'stripe';
993
+ if (['lemonsqueezy', 'lemon-squeezy', 'lemon', 'ls'].includes(normalized))
994
+ return 'lemonsqueezy';
995
+ if (['adapty', 'adapty-paywalls', 'adapty-subscriptions'].includes(normalized))
996
+ return 'adapty';
997
+ if (['superwall', 'superwall-paywalls'].includes(normalized))
998
+ return 'superwall';
999
+ if (['google-play', 'google-play-console', 'play-console', 'play-store', 'android-store'].includes(normalized))
1000
+ return 'google-play';
1001
+ if (['datadog', 'datadog-rum', 'datadog-apm', 'datadog-logs'].includes(normalized))
1002
+ return 'datadog';
1003
+ if (['bugsnag', 'bugsnag-crashes'].includes(normalized))
1004
+ return 'bugsnag';
1005
+ if (['intercom', 'intercom-support'].includes(normalized))
1006
+ return 'intercom';
1007
+ if (['zendesk', 'zendesk-support'].includes(normalized))
1008
+ return 'zendesk';
1009
+ if (['apple-search-ads', 'apple-ads', 'asa', 'search-ads'].includes(normalized))
1010
+ return 'apple-search-ads';
1011
+ if (['google-ads', 'adwords'].includes(normalized))
1012
+ return 'google-ads';
1013
+ if (['meta-ads', 'facebook-ads', 'instagram-ads', 'fb-ads'].includes(normalized))
1014
+ return 'meta-ads';
1015
+ if (['tiktok-ads', 'tiktok-business', 'tiktok-business-api'].includes(normalized))
1016
+ return 'tiktok-ads';
1017
+ if (['vercel', 'vercel-deployments', 'vercel-hosting'].includes(normalized))
1018
+ return 'vercel';
1019
+ if (['cloudflare', 'cf', 'cloudflare-workers', 'cloudflare-pages'].includes(normalized))
1020
+ return 'cloudflare';
1021
+ if (['resend', 'resend-email'].includes(normalized))
1022
+ return 'resend';
1023
+ if (['customerio', 'customer-io', 'customer.io', 'cio'].includes(normalized))
1024
+ return 'customerio';
1025
+ if (['mailchimp', 'mailchimp-marketing'].includes(normalized))
1026
+ return 'mailchimp';
1027
+ if (['appfollow', 'app-follow'].includes(normalized))
1028
+ return 'appfollow';
1029
+ if (['apptweak', 'app-tweak'].includes(normalized))
1030
+ return 'apptweak';
1031
+ if (['linear', 'linear-issues', 'linear-planning'].includes(normalized))
1032
+ return 'linear';
1033
+ if (['postiz', 'postiz-api', 'social-publishing', 'social-scheduler'].includes(normalized))
1034
+ return 'postiz';
239
1035
  return null;
240
1036
  }
241
1037
  function parseConnectorList(value) {
@@ -261,13 +1057,27 @@ function isConnectorLocallyConfigured(key) {
261
1057
  return Boolean(process.env.GITHUB_TOKEN?.trim());
262
1058
  if (key === 'revenuecat')
263
1059
  return Boolean(process.env.REVENUECAT_API_KEY?.trim());
1060
+ if (key === 'paddle')
1061
+ return Boolean(process.env.PADDLE_API_KEY?.trim());
1062
+ if (key === 'seo') {
1063
+ return Boolean(process.env.GOOGLE_SEARCH_CONSOLE_ACCESS_TOKEN?.trim() ||
1064
+ process.env.GSC_ACCESS_TOKEN?.trim() ||
1065
+ process.env.GOOGLE_APPLICATION_CREDENTIALS?.trim() ||
1066
+ process.env.GSC_SERVICE_ACCOUNT_JSON?.trim());
1067
+ }
264
1068
  if (key === 'sentry')
265
1069
  return Boolean(process.env.SENTRY_AUTH_TOKEN?.trim());
1070
+ if (key === 'coolify')
1071
+ return Boolean(process.env.COOLIFY_API_TOKEN?.trim() && process.env.COOLIFY_BASE_URL?.trim());
266
1072
  if (key === 'asc') {
267
1073
  return Boolean(process.env.ASC_KEY_ID?.trim() &&
268
1074
  process.env.ASC_ISSUER_ID?.trim() &&
269
1075
  (process.env.ASC_PRIVATE_KEY_PATH?.trim() || process.env.ASC_PRIVATE_KEY?.trim()));
270
1076
  }
1077
+ const accountConnector = getAccountSignalConnectorDefinition(key);
1078
+ if (accountConnector) {
1079
+ return accountConnector.credentials.some((credential) => Boolean(process.env[credential.env]?.trim()));
1080
+ }
271
1081
  return false;
272
1082
  }
273
1083
  function getRequiredConnectorKeys() {
@@ -374,6 +1184,170 @@ async function askMenuChoice(rl, { title, subtitle = 'Use Up/Down to move, Enter
374
1184
  }
375
1185
  }
376
1186
  }
1187
+ async function askMultiChoice(rl, { title, subtitle = 'Use Up/Down to move, Space to toggle, Enter to continue.', options, defaultValues, requiredValues = [], minSelections = 1, renderHeader, }) {
1188
+ const required = new Set(requiredValues);
1189
+ const normalizeSelection = (values) => {
1190
+ const selected = new Set(values);
1191
+ requiredValues.forEach((value) => selected.add(value));
1192
+ return options.map((option) => option.value).filter((value) => selected.has(value));
1193
+ };
1194
+ if (!process.stdin.isTTY || !process.stdout.isTTY || !process.stdin.setRawMode) {
1195
+ process.stdout.write(`\n${title}\n`);
1196
+ options.forEach((option, index) => {
1197
+ const checked = defaultValues.includes(option.value) || required.has(option.value) ? 'x' : ' ';
1198
+ const requiredLabel = required.has(option.value) ? ' required' : '';
1199
+ process.stdout.write(` ${index + 1}) [${checked}] ${option.label}${requiredLabel}: ${option.detail}\n`);
1200
+ });
1201
+ const answer = await ask(rl, `Select one or more (comma-separated 1-${options.length})`, normalizeSelection(defaultValues).map((value) => String(options.findIndex((option) => option.value === value) + 1)).join(','));
1202
+ const selected = answer
1203
+ .split(',')
1204
+ .map((value) => Number.parseInt(value.trim(), 10) - 1)
1205
+ .filter((index) => options[index])
1206
+ .map((index) => options[index].value);
1207
+ const normalized = normalizeSelection(selected);
1208
+ return normalized.length >= minSelections ? normalized : normalizeSelection(defaultValues);
1209
+ }
1210
+ rl.pause();
1211
+ let completed = false;
1212
+ try {
1213
+ const selected = await askMultiChoiceByKeys({
1214
+ title,
1215
+ subtitle,
1216
+ options,
1217
+ defaultValues: normalizeSelection(defaultValues),
1218
+ requiredValues,
1219
+ minSelections,
1220
+ renderHeader,
1221
+ });
1222
+ completed = true;
1223
+ return selected;
1224
+ }
1225
+ finally {
1226
+ if (completed) {
1227
+ rl.resume();
1228
+ }
1229
+ else {
1230
+ process.stdin.pause();
1231
+ }
1232
+ }
1233
+ }
1234
+ async function askMultiChoiceByKeys({ title, subtitle, options, defaultValues, requiredValues, minSelections, renderHeader, }) {
1235
+ emitKeypressEvents(process.stdin);
1236
+ const wasRaw = process.stdin.isRaw;
1237
+ const wasPaused = process.stdin.isPaused();
1238
+ process.stdin.setRawMode(true);
1239
+ process.stdin.resume();
1240
+ const required = new Set(requiredValues);
1241
+ const selected = new Set(defaultValues);
1242
+ requiredValues.forEach((value) => selected.add(value));
1243
+ let cursorIndex = 0;
1244
+ let warning = '';
1245
+ return await new Promise((resolve, reject) => {
1246
+ const cleanup = () => {
1247
+ process.stdin.off('keypress', onKeypress);
1248
+ process.stdin.setRawMode(Boolean(wasRaw));
1249
+ if (wasPaused) {
1250
+ process.stdin.pause();
1251
+ }
1252
+ process.stdout.write(ANSI.showCursor);
1253
+ };
1254
+ const selectedValues = () => options.map((option) => option.value).filter((value) => selected.has(value));
1255
+ const render = () => {
1256
+ process.stdout.write('\x1b[2J\x1b[H');
1257
+ renderHeader?.();
1258
+ process.stdout.write(`\n${ANSI.bold}${title}${ANSI.reset}\n`);
1259
+ process.stdout.write(`${ANSI.dim}${subtitle}${ANSI.reset}\n\n`);
1260
+ if (warning) {
1261
+ process.stdout.write(`${ANSI.cyan}${warning}${ANSI.reset}\n\n`);
1262
+ }
1263
+ for (let index = 0; index < options.length; index += 1) {
1264
+ const option = options[index];
1265
+ const pointer = index === cursorIndex ? `${ANSI.cyan}>${ANSI.reset}` : ' ';
1266
+ const checkbox = selected.has(option.value) ? '[x]' : '[ ]';
1267
+ const requiredLabel = required.has(option.value) ? ` ${ANSI.dim}(required)${ANSI.reset}` : '';
1268
+ process.stdout.write(`${pointer} ${checkbox} ${index + 1}) ${ANSI.bold}${option.label}${ANSI.reset}${requiredLabel}\n`);
1269
+ writeWrapped(option.detail, ' ', ANSI.dim);
1270
+ }
1271
+ process.stdout.write(`\n${ANSI.dim}Esc/Q cancels. Space toggles, A toggles all optional items, Enter continues. Number keys 1-${options.length} toggle items.${ANSI.reset}\n`);
1272
+ };
1273
+ const cancel = () => {
1274
+ cleanup();
1275
+ process.stdout.write('\n');
1276
+ reject(new WizardAbortError('Setup cancelled.'));
1277
+ };
1278
+ const finish = () => {
1279
+ const values = selectedValues();
1280
+ if (values.length < minSelections) {
1281
+ warning = `Select at least ${minSelections} item${minSelections === 1 ? '' : 's'} to continue.`;
1282
+ render();
1283
+ return;
1284
+ }
1285
+ cleanup();
1286
+ process.stdout.write('\x1b[2J\x1b[H');
1287
+ resolve(values);
1288
+ };
1289
+ const toggleIndex = (index) => {
1290
+ const option = options[index];
1291
+ if (!option)
1292
+ return;
1293
+ warning = '';
1294
+ if (required.has(option.value)) {
1295
+ selected.add(option.value);
1296
+ warning = `${option.label} is required.`;
1297
+ return;
1298
+ }
1299
+ if (selected.has(option.value))
1300
+ selected.delete(option.value);
1301
+ else
1302
+ selected.add(option.value);
1303
+ requiredValues.forEach((value) => selected.add(value));
1304
+ };
1305
+ const onKeypress = (_text, key) => {
1306
+ if (key?.ctrl && key?.name === 'c') {
1307
+ cancel();
1308
+ return;
1309
+ }
1310
+ if (key?.name === 'escape' || key?.name === 'q') {
1311
+ cancel();
1312
+ return;
1313
+ }
1314
+ if (key?.name === 'up' || key?.name === 'k') {
1315
+ cursorIndex = (cursorIndex - 1 + options.length) % options.length;
1316
+ warning = '';
1317
+ }
1318
+ else if (key?.name === 'down' || key?.name === 'j') {
1319
+ cursorIndex = (cursorIndex + 1) % options.length;
1320
+ warning = '';
1321
+ }
1322
+ else if (key?.name === 'space') {
1323
+ toggleIndex(cursorIndex);
1324
+ }
1325
+ else if (String(_text || '').toLowerCase() === 'a') {
1326
+ const optional = options.filter((option) => !required.has(option.value));
1327
+ const allSelected = optional.every((option) => selected.has(option.value));
1328
+ optional.forEach((option) => {
1329
+ if (allSelected)
1330
+ selected.delete(option.value);
1331
+ else
1332
+ selected.add(option.value);
1333
+ });
1334
+ requiredValues.forEach((value) => selected.add(value));
1335
+ warning = '';
1336
+ }
1337
+ else if (key?.name === 'return' || key?.name === 'enter') {
1338
+ finish();
1339
+ return;
1340
+ }
1341
+ else if (/^[1-9]$/.test(String(_text || ''))) {
1342
+ toggleIndex(Number(_text) - 1);
1343
+ }
1344
+ render();
1345
+ };
1346
+ process.stdin.on('keypress', onKeypress);
1347
+ process.stdout.write(ANSI.hideCursor);
1348
+ render();
1349
+ });
1350
+ }
377
1351
  async function askMenuChoiceByKeys({ title, subtitle, options, defaultValue, renderHeader, }) {
378
1352
  emitKeypressEvents(process.stdin);
379
1353
  const wasRaw = process.stdin.isRaw;
@@ -456,10 +1430,19 @@ function normalizeConnectorProgressKey(key) {
456
1430
  return 'github';
457
1431
  if (normalized === 'revenuecat')
458
1432
  return 'revenuecat';
1433
+ if (normalized === 'paddle')
1434
+ return 'paddle';
1435
+ if (normalized === 'seo' || normalized === 'gsc' || normalized === 'google-search-console')
1436
+ return 'seo';
459
1437
  if (normalized === 'sentry')
460
1438
  return 'sentry';
1439
+ if (normalized === 'coolify')
1440
+ return 'coolify';
461
1441
  if (normalized === 'asc' || normalized === 'appstoreconnect' || normalized === 'app-store-connect')
462
1442
  return 'asc';
1443
+ const accountConnector = normalizeConnectorKey(normalized);
1444
+ if (accountConnector && accountConnector !== 'all')
1445
+ return accountConnector;
463
1446
  return null;
464
1447
  }
465
1448
  async function withConnectorHealthLoading(taskFactory) {
@@ -620,14 +1603,17 @@ async function getConnectorPickerHealth(configPath, onProgress = () => { }) {
620
1603
  },
621
1604
  ]));
622
1605
  }
623
- const result = await runCommandCaptureWithProgress(`node scripts/openclaw-growth-status.mjs --config ${quote(configPath)} --json --progress-json`, onProgress);
1606
+ const result = await runCommandCaptureWithProgress(`${nodeRuntimeScriptCommand('openclaw-growth-status.mjs')} --config ${quote(configPath)} --json --progress-json`, onProgress);
624
1607
  const payload = parseJsonFromStdout(result.stdout);
625
1608
  const connectors = payload?.connectors && typeof payload.connectors === 'object' ? payload.connectors : {};
626
1609
  const healthByConnector = {
627
1610
  analytics: connectors.analyticscli,
628
1611
  github: connectors.github,
629
1612
  revenuecat: connectors.revenuecat,
1613
+ paddle: connectors.paddle,
1614
+ seo: connectors.seo,
630
1615
  sentry: connectors.sentry,
1616
+ coolify: connectors.coolify,
631
1617
  asc: connectors.appStoreConnect,
632
1618
  };
633
1619
  return Object.fromEntries(CONNECTOR_KEYS.map((key) => [key, getConnectorHealth(key, healthByConnector)]));
@@ -1033,9 +2019,22 @@ function summarizeFailureFix(connector, blockers) {
1033
2019
  if (connector === 'revenuecat') {
1034
2020
  return 'Paste a RevenueCat v2 secret API key with read-only project permissions, then rerun setup.';
1035
2021
  }
2022
+ if (connector === 'paddle') {
2023
+ return 'Paste a Paddle API key with metrics.read permission for the live account, then rerun setup.';
2024
+ }
2025
+ if (connector === 'seo') {
2026
+ return 'Configure Search Console read access. Leave GSC_SITE_URL empty to scan all verified properties in the account, or set it only when you intentionally want one property.';
2027
+ }
2028
+ if (connector === 'coolify') {
2029
+ return 'Paste a Coolify base URL and read-only API token from Keys & Tokens / API tokens, then rerun setup.';
2030
+ }
1036
2031
  if (connector === 'asc') {
1037
2032
  return 'Rerun ASC setup and verify ASC credentials, key role access, and `asc apps list --output json`.';
1038
2033
  }
2034
+ if (isAccountSignalConnector(connector)) {
2035
+ const definition = getAccountSignalConnectorDefinition(connector);
2036
+ return `Paste ${definition?.credentials.map((credential) => credential.env).join(' / ') || connector} in the connector wizard. Keep setup account-wide; do not add project, app, product, paywall, or service IDs unless a later run explicitly narrows scope.`;
2037
+ }
1039
2038
  return blockers.find((blocker) => blocker.remediation)?.remediation || 'Fix the failing configuration and rerun setup.';
1040
2039
  }
1041
2040
  function connectorForBlocker(blocker) {
@@ -1161,10 +2160,22 @@ function connectorFromCheckName(name) {
1161
2160
  return 'github';
1162
2161
  if (value.includes('revenuecat') || value.includes('REVENUECAT'))
1163
2162
  return 'revenuecat';
2163
+ if (value.includes('paddle') || value.includes('PADDLE'))
2164
+ return 'paddle';
2165
+ if (value.includes('seo') || value.includes('GSC') || value.includes('GOOGLE_SEARCH_CONSOLE'))
2166
+ return 'seo';
1164
2167
  if (value.includes('sentry') || value.includes('SENTRY') || value.includes('GLITCHTIP'))
1165
2168
  return 'sentry';
2169
+ if (value.includes('coolify') || value.includes('COOLIFY'))
2170
+ return 'coolify';
1166
2171
  if (value.includes('asc') || value.includes('ASC_'))
1167
2172
  return 'asc';
2173
+ for (const key of ACCOUNT_SIGNAL_CONNECTOR_KEYS) {
2174
+ const definition = getAccountSignalConnectorDefinition(key);
2175
+ const envMatch = definition?.credentials.some((credential) => value.includes(credential.env));
2176
+ if (value.includes(key) || value.includes(key.toUpperCase()) || envMatch)
2177
+ return key;
2178
+ }
1168
2179
  return null;
1169
2180
  }
1170
2181
  function connectorTitle(key) {
@@ -1304,9 +2315,29 @@ function buildSetupTestProgressPlan(selected) {
1304
2315
  if (selectedSet.has('revenuecat')) {
1305
2316
  items.push({ key: 'revenuecat', label: 'RevenueCat', detail: 'waiting for API key auth + project read', status: 'pending' });
1306
2317
  }
2318
+ if (selectedSet.has('paddle')) {
2319
+ items.push({ key: 'paddle', label: 'Paddle', detail: 'waiting for metrics API auth + revenue read', status: 'pending' });
2320
+ }
2321
+ if (selectedSet.has('seo')) {
2322
+ items.push({ key: 'seo', label: 'SEO / GSC', detail: 'waiting for Search Console auth or CSV/DataForSEO config', status: 'pending' });
2323
+ }
2324
+ if (selectedSet.has('coolify')) {
2325
+ items.push({ key: 'coolify', label: 'Coolify', detail: 'waiting for API key auth + deployment/resource read', status: 'pending' });
2326
+ }
1307
2327
  if (selectedSet.has('github')) {
1308
2328
  items.push({ key: 'github', label: 'GitHub', detail: 'waiting for repo/token access check', status: 'pending' });
1309
2329
  }
2330
+ for (const key of ACCOUNT_SIGNAL_CONNECTOR_KEYS) {
2331
+ if (!selectedSet.has(key))
2332
+ continue;
2333
+ const definition = getAccountSignalConnectorDefinition(key);
2334
+ items.push({
2335
+ key,
2336
+ label: definition?.label || key,
2337
+ detail: 'waiting for account-wide credential presence and source wiring',
2338
+ status: 'pending',
2339
+ });
2340
+ }
1310
2341
  items.push({
1311
2342
  key: 'finalize',
1312
2343
  label: 'Finalizing result',
@@ -1366,21 +2397,9 @@ async function runImmediateConnectorHealthCheck({ rl, configPath, connector, sec
1366
2397
  ...process.env,
1367
2398
  ...secrets,
1368
2399
  };
1369
- const command = `node scripts/openclaw-growth-start.mjs --config ${quote(configPath)} --setup-only --connectors ${quote(connector)} --only-connectors ${quote(connector)}`;
2400
+ const command = `${nodeRuntimeScriptCommand('openclaw-growth-start.mjs')} --config ${quote(configPath)} --setup-only --connectors ${quote(connector)} --only-connectors ${quote(connector)}`;
1370
2401
  let result = await runSetupCommandWithProgress(command, env, [connector], `Checking ${connectorLabel(connector)} immediately after setup...`);
1371
2402
  let payload = parseJsonFromStdout(result.stdout);
1372
- if (connector === 'asc') {
1373
- try {
1374
- const ascWebAuthChanged = await ensureAscWebAnalyticsAuth(rl, secrets);
1375
- if (ascWebAuthChanged) {
1376
- result = await runSetupCommandWithProgress(command, env, [connector], 'Retesting ASC after web analytics login...');
1377
- payload = parseJsonFromStdout(result.stdout);
1378
- }
1379
- }
1380
- catch (error) {
1381
- process.stdout.write(`ASC web analytics still needs attention: ${error instanceof Error ? error.message : String(error)}\n`);
1382
- }
1383
- }
1384
2403
  if (payloadHasConnectorFailures(payload, connector)) {
1385
2404
  process.stdout.write(`\n${connectorLabel(connector)} needs attention before continuing.\n`);
1386
2405
  printConciseSetupBlockers(payload, command, {
@@ -1748,13 +2767,13 @@ function getGrowthRunCommand(config, displayConfigPath) {
1748
2767
  if (config?.security?.connectorSecrets?.mode === 'isolated-runner' && config.security.connectorSecrets.runCommand) {
1749
2768
  return config.security.connectorSecrets.runCommand;
1750
2769
  }
1751
- return `node scripts/openclaw-growth-runner.mjs --config ${displayConfigPath}`;
2770
+ return buildGrowthRunnerCommand(displayConfigPath, deriveStatePathFromConfigPath(displayConfigPath));
1752
2771
  }
1753
2772
  function getConnectorHealthCommand(config, displayConfigPath) {
1754
2773
  if (config?.security?.connectorSecrets?.mode === 'isolated-runner' && config.security.connectorSecrets.healthCommand) {
1755
2774
  return config.security.connectorSecrets.healthCommand;
1756
2775
  }
1757
- return `node scripts/openclaw-growth-runner.mjs --config ${displayConfigPath}`;
2776
+ return buildGrowthRunnerCommand(displayConfigPath, deriveStatePathFromConfigPath(displayConfigPath));
1758
2777
  }
1759
2778
  async function maybePromptSecret(rl, label, envName) {
1760
2779
  const existing = process.env[envName]?.trim();
@@ -1977,6 +2996,8 @@ async function upsertSentryAccountsConfig(configPath, accounts) {
1977
2996
  : [];
1978
2997
  const merged = new Map();
1979
2998
  for (const account of existingAccounts) {
2999
+ if (isPlaceholderSentryAccount(account))
3000
+ continue;
1980
3001
  const id = String(account?.id || account?.key || account?.label || '').trim();
1981
3002
  if (id)
1982
3003
  merged.set(id, account);
@@ -1989,14 +3010,76 @@ async function upsertSentryAccountsConfig(configPath, accounts) {
1989
3010
  }
1990
3011
  config.sources = {
1991
3012
  ...(config.sources || {}),
1992
- sentry: {
1993
- ...(config.sources?.sentry || {}),
3013
+ sentry: {
3014
+ ...(config.sources?.sentry || {}),
3015
+ enabled: true,
3016
+ mode: 'command',
3017
+ command: normalizeWizardSourceCommand('sentry', config.sources?.sentry || {}, configPath),
3018
+ accounts: [...merged.values()],
3019
+ },
3020
+ };
3021
+ await writeJsonFile(configPath, config);
3022
+ return true;
3023
+ }
3024
+ function isPlaceholderSentryAccount(account) {
3025
+ const baseUrl = String(account?.baseUrl || account?.base_url || account?.url || '').trim().toLowerCase();
3026
+ const org = String(account?.org || account?.organization || '').trim().toLowerCase();
3027
+ const projects = Array.isArray(account?.projects)
3028
+ ? account.projects.map((project) => String(project || '').trim().toLowerCase())
3029
+ : [];
3030
+ return (org === 'owner-org' ||
3031
+ baseUrl.includes('example.com') ||
3032
+ projects.includes('ios-app') ||
3033
+ projects.includes('backend-api') ||
3034
+ projects.includes('web-app'));
3035
+ }
3036
+ async function verifySentryAccountsConfig(configPath, expectedAccounts) {
3037
+ if (!(await fileExists(configPath))) {
3038
+ return { ok: false, detail: `${configPath} does not exist` };
3039
+ }
3040
+ const config = await readJsonFile(configPath);
3041
+ const source = config?.sources?.sentry;
3042
+ if (!source || source.enabled !== true) {
3043
+ return { ok: false, detail: 'sources.sentry.enabled is not true' };
3044
+ }
3045
+ if (source.mode !== 'command') {
3046
+ return { ok: false, detail: 'sources.sentry.mode is not command' };
3047
+ }
3048
+ const configuredAccounts = Array.isArray(source.accounts) ? source.accounts : [];
3049
+ const realAccounts = configuredAccounts.filter((account) => !isPlaceholderSentryAccount(account));
3050
+ if (realAccounts.length === 0) {
3051
+ return { ok: false, detail: 'sources.sentry.accounts contains no non-placeholder account' };
3052
+ }
3053
+ const configuredIds = new Set(realAccounts.map((account) => String(account?.id || account?.key || '').trim()).filter(Boolean));
3054
+ const missingIds = expectedAccounts
3055
+ .map((account) => String(account?.id || '').trim())
3056
+ .filter((id) => id && !configuredIds.has(id));
3057
+ if (missingIds.length > 0) {
3058
+ return { ok: false, detail: `sources.sentry.accounts is missing configured account id(s): ${missingIds.join(', ')}` };
3059
+ }
3060
+ return { ok: true, detail: `${realAccounts.length} active Sentry-compatible account(s) configured` };
3061
+ }
3062
+ async function upsertCoolifyConfig(configPath, { baseUrl, tokenEnv = 'COOLIFY_API_TOKEN' }) {
3063
+ if (!(await fileExists(configPath)))
3064
+ return false;
3065
+ const coolifyCommand = `${getWizardDefaultSourceCommand('coolify')} --config ${quote(configPath)}`;
3066
+ const config = await readJsonFile(configPath);
3067
+ config.sources = {
3068
+ ...(config.sources || {}),
3069
+ coolify: {
3070
+ ...(config.sources?.coolify || {}),
1994
3071
  enabled: true,
1995
3072
  mode: 'command',
1996
- command: getDefaultSourceCommand('sentry'),
1997
- accounts: [...merged.values()],
3073
+ command: coolifyCommand,
3074
+ baseUrl,
3075
+ tokenEnv,
1998
3076
  },
1999
3077
  };
3078
+ config.secrets = {
3079
+ ...(config.secrets || {}),
3080
+ coolifyTokenEnv: tokenEnv,
3081
+ coolifyTokenRef: { source: 'env', provider: 'default', id: tokenEnv },
3082
+ };
2000
3083
  await writeJsonFile(configPath, config);
2001
3084
  return true;
2002
3085
  }
@@ -2176,90 +3259,6 @@ async function askAscPrivateKeyPath(rl) {
2176
3259
  process.stdout.write('The ASC private key path was not saved. Paste a valid path, or leave empty to skip.\n');
2177
3260
  }
2178
3261
  }
2179
- function isAscWebAuthAuthenticated(stdout) {
2180
- try {
2181
- const payload = JSON.parse(String(stdout || '{}'));
2182
- return payload?.authenticated === true;
2183
- }
2184
- catch {
2185
- return false;
2186
- }
2187
- }
2188
- function resolveAscWebAppleId() {
2189
- return (process.env.ASC_WEB_APPLE_ID?.trim() ||
2190
- process.env.ASC_APPLE_ID?.trim() ||
2191
- process.env.APPLE_ID?.trim() ||
2192
- '');
2193
- }
2194
- function ascWebAuthEnv() {
2195
- return {
2196
- ...process.env,
2197
- ASC_TIMEOUT: process.env.ASC_TIMEOUT || '90s',
2198
- ASC_TIMEOUT_SECONDS: process.env.ASC_TIMEOUT_SECONDS || '90',
2199
- };
2200
- }
2201
- async function ensureAscWebAnalyticsAuth(rl = null, secrets = {}) {
2202
- process.stdout.write('\nChecking ASC web analytics authentication...\n');
2203
- process.stdout.write('Still working: verifying whether the ASC web session is active.\n');
2204
- if (!(await commandExists('asc'))) {
2205
- throw new Error('The asc CLI is not installed yet. Install it with `openclaw start --connectors asc`, then rerun the connector wizard so it can run `asc web auth login`.');
2206
- }
2207
- const ascEnv = ascWebAuthEnv();
2208
- if (!process.env.ASC_TIMEOUT && !process.env.ASC_TIMEOUT_SECONDS) {
2209
- process.stdout.write('Using ASC_TIMEOUT=90s for ASC web auth because Apple web endpoints can be slow.\n');
2210
- }
2211
- const status = await runCommandCapture('asc web auth status --output json', { env: ascEnv });
2212
- if (status.ok && isAscWebAuthAuthenticated(status.stdout)) {
2213
- process.stdout.write('ASC web analytics authentication is active.\n');
2214
- return false;
2215
- }
2216
- let appleId = resolveAscWebAppleId();
2217
- if (!appleId && rl) {
2218
- appleId = (await ask(rl, 'Apple Account email for ASC web analytics login (ASC_WEB_APPLE_ID)', '')).trim();
2219
- if (appleId) {
2220
- secrets.ASC_WEB_APPLE_ID = appleId;
2221
- await saveSecretsImmediately({ ASC_WEB_APPLE_ID: appleId });
2222
- }
2223
- }
2224
- if (!appleId) {
2225
- throw new Error('ASC web analytics login needs an Apple Account email. Rerun the connector wizard and enter ASC_WEB_APPLE_ID.');
2226
- }
2227
- let attempts = 0;
2228
- while (true) {
2229
- attempts += 1;
2230
- process.stdout.write(`\nASC web login: ${appleId}\n`);
2231
- process.stdout.write('The next prompts are from asc. Enter the Apple Account password/2FA there.\n\n');
2232
- const loginCode = await runInteractiveProcess('asc', ['web', 'auth', 'login', '--apple-id', appleId], {
2233
- env: ascEnv,
2234
- rl,
2235
- });
2236
- if (loginCode === 0) {
2237
- break;
2238
- }
2239
- process.stdout.write('\nASC web login failed.\n');
2240
- process.stdout.write('Reason: asc/Apple rejected the Apple Account login. The .p8 API key is not used here.\n\n');
2241
- if (!rl || attempts >= 3) {
2242
- throw new Error('ASC web analytics login failed. Check the Apple Account email/password/2FA, then rerun the connector wizard.');
2243
- }
2244
- const retry = await askYesNo(rl, 'Retry ASC web analytics login now?', true);
2245
- if (!retry) {
2246
- throw new Error('ASC web analytics login was not completed. Rerun the connector wizard when the Apple Account login is ready.');
2247
- }
2248
- const nextAppleId = (await ask(rl, 'Apple Account email for ASC web analytics login (press Enter to keep)', appleId)).trim();
2249
- if (nextAppleId && nextAppleId !== appleId) {
2250
- appleId = nextAppleId;
2251
- secrets.ASC_WEB_APPLE_ID = appleId;
2252
- await saveSecretsImmediately({ ASC_WEB_APPLE_ID: appleId });
2253
- }
2254
- }
2255
- process.stdout.write('\nStill working: verifying the ASC web analytics session after login...\n');
2256
- const verify = await runCommandCapture('asc web auth status --output json', { env: ascEnv });
2257
- if (!verify.ok || !isAscWebAuthAuthenticated(verify.stdout)) {
2258
- throw new Error('ASC web analytics login did not verify. Run `asc web auth status --output json --pretty` to inspect the session, then rerun the connector wizard.');
2259
- }
2260
- process.stdout.write('ASC web analytics authentication verified.\n');
2261
- return true;
2262
- }
2263
3262
  function printSection(title, lines = []) {
2264
3263
  process.stdout.write(`\n${ANSI.bold}${title}${ANSI.reset}\n`);
2265
3264
  process.stdout.write(`${'-'.repeat(title.length)}\n`);
@@ -2357,6 +3356,121 @@ async function guideRevenueCatConnector(rl, secrets) {
2357
3356
  if (apiKey)
2358
3357
  secrets.REVENUECAT_API_KEY = apiKey;
2359
3358
  }
3359
+ async function guidePaddleConnector(rl, secrets) {
3360
+ printSection('Paddle Billing metrics', [
3361
+ 'Use this when OpenClaw should read web checkout, revenue, MRR, refunds, chargebacks, and active subscriber metrics.',
3362
+ ]);
3363
+ process.stdout.write('\nCreate or update a Paddle API key here:\n https://vendors.paddle.com/authentication\n\n');
3364
+ printBullets([
3365
+ 'Open Paddle > Developer Tools > Authentication.',
3366
+ 'Create a new API key for the live account when you want production revenue evidence.',
3367
+ 'Grant `metrics.read`. Keep write permissions off unless another workflow explicitly needs them.',
3368
+ 'Do not select or hard-code a single product in the wizard; the Growth Engineer should keep account-level metrics context.',
3369
+ 'Paste the key here so it is stored only in the local chmod 600 secrets file.',
3370
+ ]);
3371
+ const apiKey = await maybePromptSecret(rl, 'Paste PADDLE_API_KEY into this local terminal', 'PADDLE_API_KEY');
3372
+ if (apiKey)
3373
+ secrets.PADDLE_API_KEY = apiKey;
3374
+ }
3375
+ async function guideSeoConnector(rl, secrets) {
3376
+ printSection('SEO / Google Search Console / DataForSEO', [
3377
+ 'Use this when OpenClaw should read organic search demand, GSC clicks/impressions/CTR/position, and optional paid keyword ideas.',
3378
+ ]);
3379
+ process.stdout.write('\nGoogle Search Console:\n https://search.google.com/search-console\nGoogle Cloud service accounts:\n https://console.cloud.google.com/iam-admin/serviceaccounts\nDataForSEO API dashboard:\n https://app.dataforseo.com/api-dashboard\n\n');
3380
+ printBullets([
3381
+ 'Preferred: give the token/service account access to all Search Console properties you want analyzed.',
3382
+ 'Leave the property URL empty to let the exporter list and query all verified GSC properties in the account.',
3383
+ 'Enter a property URL only when you intentionally want to restrict analysis to one site.',
3384
+ 'For OAuth token mode, paste a read-only Search Console token with `webmasters.readonly` scope.',
3385
+ 'For service-account mode, add the service account email as a restricted/full user in Search Console, then set GOOGLE_APPLICATION_CREDENTIALS or GSC_SERVICE_ACCOUNT_JSON outside this wizard.',
3386
+ 'DataForSEO is optional and paid. The exporter refuses paid calls unless the source command includes --confirm-paid and a small --max-paid-requests cap.',
3387
+ 'CSV-only mode is also supported with --gsc-csv or --csv in sources.seo.command.',
3388
+ ]);
3389
+ const siteUrl = await ask(rl, 'Optional GSC property URL (empty = all verified properties)', process.env.GSC_SITE_URL || '');
3390
+ if (siteUrl.trim())
3391
+ secrets.GSC_SITE_URL = siteUrl.trim();
3392
+ const gscToken = await maybePromptSecret(rl, 'Paste GOOGLE_SEARCH_CONSOLE_ACCESS_TOKEN, or leave empty for service-account/all-sites/CSV mode', 'GOOGLE_SEARCH_CONSOLE_ACCESS_TOKEN');
3393
+ if (gscToken)
3394
+ secrets.GOOGLE_SEARCH_CONSOLE_ACCESS_TOKEN = gscToken;
3395
+ const useDataForSeo = await askYesNo(rl, 'Also store DataForSEO credentials for optional paid keyword research?', false);
3396
+ if (useDataForSeo) {
3397
+ const login = await maybePromptSecret(rl, 'Paste DATAFORSEO_LOGIN into this local terminal', 'DATAFORSEO_LOGIN');
3398
+ const password = await maybePromptSecret(rl, 'Paste DATAFORSEO_PASSWORD into this local terminal', 'DATAFORSEO_PASSWORD');
3399
+ if (login)
3400
+ secrets.DATAFORSEO_LOGIN = login;
3401
+ if (password)
3402
+ secrets.DATAFORSEO_PASSWORD = password;
3403
+ }
3404
+ }
3405
+ function buildAccountSignalExtraSourceConfig(key, existing = {}) {
3406
+ const definition = getAccountSignalConnectorDefinition(key);
3407
+ if (!definition)
3408
+ return existing;
3409
+ return {
3410
+ ...buildExtraSourceConfig(definition.service, {
3411
+ key: definition.key,
3412
+ label: definition.label,
3413
+ enabled: true,
3414
+ mode: 'file',
3415
+ secretEnv: definition.credentials[0]?.env || null,
3416
+ hint: definition.signalHint,
3417
+ }),
3418
+ ...existing,
3419
+ key: definition.key,
3420
+ label: definition.label,
3421
+ service: definition.service,
3422
+ enabled: true,
3423
+ mode: existing.mode || 'file',
3424
+ path: existing.path || getDefaultSourcePath(definition.key),
3425
+ secretEnv: existing.secretEnv || definition.credentials[0]?.env || null,
3426
+ accountWide: true,
3427
+ projectScope: 'discover_from_account',
3428
+ docsUrl: definition.docsUrl,
3429
+ signalKind: definition.sourceKind,
3430
+ experimental: Boolean(definition.experimental),
3431
+ hint: existing.hint || definition.signalHint,
3432
+ };
3433
+ }
3434
+ async function upsertAccountSignalConnectorConfig(configPath, key) {
3435
+ const definition = getAccountSignalConnectorDefinition(key);
3436
+ if (!definition)
3437
+ return false;
3438
+ const config = await loadEditableConfig(configPath);
3439
+ const sources = config.sources && typeof config.sources === 'object' ? config.sources : {};
3440
+ const extra = Array.isArray(sources.extra) ? sources.extra : [];
3441
+ const nextExtra = extra.filter((source) => String(source?.key || source?.service || '') !== definition.key);
3442
+ const existing = extra.find((source) => String(source?.key || source?.service || '') === definition.key) || {};
3443
+ nextExtra.push(buildAccountSignalExtraSourceConfig(key, existing));
3444
+ config.sources = {
3445
+ ...sources,
3446
+ extra: nextExtra,
3447
+ };
3448
+ await writeJsonFile(configPath, config);
3449
+ return true;
3450
+ }
3451
+ async function guideAccountSignalConnector(rl, secrets, key) {
3452
+ const definition = getAccountSignalConnectorDefinition(key);
3453
+ if (!definition)
3454
+ return;
3455
+ printSection(definition.label, [
3456
+ definition.summary,
3457
+ 'Setup is account-wide. Do not paste project IDs, app IDs, product IDs, package names, paywall IDs, service names, or tags here.',
3458
+ ]);
3459
+ process.stdout.write(`Docs: ${definition.docsUrl}\n\n`);
3460
+ printBullets(definition.steps);
3461
+ for (const credential of definition.credentials) {
3462
+ const defaultValue = credential.defaultValue ?? process.env[credential.env] ?? '';
3463
+ const value = credential.optional
3464
+ ? await maybePromptSecret(rl, credential.prompt, credential.env)
3465
+ : await maybePromptSecret(rl, credential.prompt, credential.env);
3466
+ const finalValue = value || defaultValue;
3467
+ if (finalValue)
3468
+ secrets[credential.env] = finalValue;
3469
+ else if (!credential.optional) {
3470
+ process.stdout.write(`${credential.env} was not saved. ${definition.label} setup remains pending; rerun this wizard when ready.\n`);
3471
+ }
3472
+ }
3473
+ }
2360
3474
  async function guideSentryConnector(rl, secrets) {
2361
3475
  printSection('Sentry / GlitchTip', [
2362
3476
  'Paste token, org, and base URL. Projects are discovered automatically.',
@@ -2461,19 +3575,54 @@ async function guideSentryConnector(rl, secrets) {
2461
3575
  }
2462
3576
  return accounts;
2463
3577
  }
3578
+ function normalizeCoolifyBaseUrl(value) {
3579
+ const raw = String(value || '').trim().replace(/\/+$/, '');
3580
+ if (!raw)
3581
+ return '';
3582
+ return /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
3583
+ }
3584
+ async function guideCoolifyConnector(rl, secrets) {
3585
+ printSection('Coolify deployment monitoring', [
3586
+ 'Use this when OpenClaw should read deployment, resource, server, and health-check signals from Coolify.',
3587
+ 'The token should be read-only. Do not use "*" or sensitive-token permissions for normal monitoring.',
3588
+ ]);
3589
+ const baseUrl = normalizeCoolifyBaseUrl(await ask(rl, 'Coolify base URL', process.env.COOLIFY_BASE_URL || 'https://coolify.wotaso.com'));
3590
+ const tokenUrl = baseUrl ? `${baseUrl}/security/api-tokens` : 'https://<your-coolify-host>/security/api-tokens';
3591
+ process.stdout.write(`\nToken page: ${tokenUrl}\n\n`);
3592
+ printBullets([
3593
+ 'Open the Coolify dashboard.',
3594
+ 'In the sidebar, go to "Keys & Tokens".',
3595
+ 'Open "API tokens".',
3596
+ 'Create a new API key/token with read-only permissions.',
3597
+ 'Copy the token once and paste it into this local terminal.',
3598
+ ]);
3599
+ const token = await maybePromptSecret(rl, 'Paste COOLIFY_API_TOKEN into this local terminal', 'COOLIFY_API_TOKEN');
3600
+ if (baseUrl)
3601
+ secrets.COOLIFY_BASE_URL = baseUrl;
3602
+ if (token)
3603
+ secrets.COOLIFY_API_TOKEN = token;
3604
+ return { baseUrl, tokenEnv: 'COOLIFY_API_TOKEN' };
3605
+ }
2464
3606
  async function guideAscConnector(rl, secrets) {
2465
3607
  printSection('App Store Connect CLI', [
2466
- 'Use this mainly for App Store analytics, plus builds, TestFlight, reviews, ratings, and store context.',
2467
- 'ASC web analytics also needs a website login; this wizard verifies it after helper setup.',
3608
+ 'Use this mainly for App Store analytics batch reports, plus builds, TestFlight, reviews, ratings, and store context.',
3609
+ 'The normal Growth Engineer path uses App Store Connect API-key reports. Experimental ASC web analytics is not part of setup.',
2468
3610
  ]);
2469
3611
  process.stdout.write('Create an App Store Connect API key here:\n https://appstoreconnect.apple.com/access/integrations/api\n\n');
2470
3612
  process.stdout.write('Roles to choose for this key:\n');
2471
3613
  printBullets([
2472
- 'Required: Sales, for App Analytics, Sales and Trends, downloads, revenue, and conversion context.',
3614
+ 'Required for first setup: Admin, because Apple only allows Admin keys to create the initial Analytics Report Request.',
3615
+ 'Required for steady-state report downloads after the request exists: Sales and Reports, Finance, or Admin.',
2473
3616
  'Recommended: Customer Support, for App Store ratings and review text.',
2474
3617
  'Recommended: Developer, for builds, TestFlight, and delivery status.',
2475
3618
  'Optional: App Manager, only if OpenClaw should also read or manage app metadata, pricing, or release settings.',
2476
- 'Avoid: Admin unless a one-off App Store Connect permission requires it.',
3619
+ 'Least privilege option: run setup once with Admin, then rotate Growth Engineer to a Sales and Reports key for ongoing analytics downloads.',
3620
+ ]);
3621
+ process.stdout.write('\nWhy Admin is requested during setup:\n');
3622
+ printBullets([
3623
+ 'Growth Engineer automatically creates an ongoing App Analytics report request when none exists.',
3624
+ 'Without that request, Apple will not generate Impressions, Product Page Views, App Units, Conversion Rate, and related report instances.',
3625
+ 'A non-Admin key can read existing reports, but creation fails with a forbidden response.',
2477
3626
  ]);
2478
3627
  process.stdout.write('\nAfter creating the key, copy these values into this wizard:\n');
2479
3628
  printBullets([
@@ -2481,6 +3630,7 @@ async function guideAscConnector(rl, secrets) {
2481
3630
  'Key ID from the API key row or from the downloaded file name: AuthKey_<KEY_ID>.p8.',
2482
3631
  'Download the .p8 file, open it, then paste the full file content into this terminal.',
2483
3632
  'If the .p8 is already on this host, leave the content prompt empty and paste the file path instead.',
3633
+ 'Vendor Number from App Store Connect Sales and Trends > Reports, needed for Sales and Trends/App Units reports.',
2484
3634
  ]);
2485
3635
  const keyId = await ask(rl, 'ASC_KEY_ID (leave empty to skip)', process.env.ASC_KEY_ID || '');
2486
3636
  const issuerId = await ask(rl, 'ASC_ISSUER_ID (leave empty to skip)', process.env.ASC_ISSUER_ID || '');
@@ -2488,10 +3638,6 @@ async function guideAscConnector(rl, secrets) {
2488
3638
  secrets.ASC_KEY_ID = keyId.trim();
2489
3639
  if (issuerId.trim())
2490
3640
  secrets.ASC_ISSUER_ID = issuerId.trim();
2491
- const appleId = await ask(rl, 'Apple Account email for ASC web analytics login (ASC_WEB_APPLE_ID, leave empty to skip)', resolveAscWebAppleId());
2492
- if (appleId.trim())
2493
- secrets.ASC_WEB_APPLE_ID = appleId.trim();
2494
- process.stdout.write('ASC web password and 2FA are not stored by this wizard; asc asks for them interactively during web login.\n');
2495
3641
  const privateKeyContent = await askAscPrivateKeyContent(rl);
2496
3642
  if (privateKeyContent) {
2497
3643
  const privateKeyPath = resolveAscPrivateKeyPath(keyId);
@@ -2506,6 +3652,9 @@ async function guideAscConnector(rl, secrets) {
2506
3652
  if (privateKeyPath.trim())
2507
3653
  secrets.ASC_PRIVATE_KEY_PATH = privateKeyPath.trim();
2508
3654
  }
3655
+ const vendorNumber = await ask(rl, 'ASC_VENDOR_NUMBER for Sales and Trends/App Units (leave empty to skip)', process.env.ASC_VENDOR_NUMBER || process.env.ASC_ANALYTICS_VENDOR_NUMBER || '');
3656
+ if (vendorNumber.trim())
3657
+ secrets.ASC_VENDOR_NUMBER = vendorNumber.trim();
2509
3658
  }
2510
3659
  async function shouldRunSelfUpdate(workspaceRoot, force) {
2511
3660
  if (force)
@@ -2611,6 +3760,7 @@ async function runConnectorSetupSteps({ rl, args, selected, healthByConnector, a
2611
3760
  process.stdout.write('\n');
2612
3761
  const secrets = {};
2613
3762
  let sentryAccounts = [];
3763
+ let coolifyConfig = null;
2614
3764
  if (selected.includes('analytics')) {
2615
3765
  let forceFreshAnalyticsToken = shouldForceFreshAnalyticsToken(healthByConnector);
2616
3766
  while (true) {
@@ -2655,6 +3805,34 @@ async function runConnectorSetupSteps({ rl, args, selected, healthByConnector, a
2655
3805
  break;
2656
3806
  }
2657
3807
  }
3808
+ if (selected.includes('paddle')) {
3809
+ while (true) {
3810
+ clearTerminal();
3811
+ await guidePaddleConnector(rl, secrets);
3812
+ const check = await runImmediateConnectorHealthCheck({
3813
+ rl,
3814
+ configPath: args.config,
3815
+ connector: 'paddle',
3816
+ secrets,
3817
+ });
3818
+ if (!check.retry)
3819
+ break;
3820
+ }
3821
+ }
3822
+ if (selected.includes('seo')) {
3823
+ while (true) {
3824
+ clearTerminal();
3825
+ await guideSeoConnector(rl, secrets);
3826
+ const check = await runImmediateConnectorHealthCheck({
3827
+ rl,
3828
+ configPath: args.config,
3829
+ connector: 'seo',
3830
+ secrets,
3831
+ });
3832
+ if (!check.retry)
3833
+ break;
3834
+ }
3835
+ }
2658
3836
  if (selected.includes('sentry')) {
2659
3837
  while (true) {
2660
3838
  clearTerminal();
@@ -2670,6 +3848,23 @@ async function runConnectorSetupSteps({ rl, args, selected, healthByConnector, a
2670
3848
  break;
2671
3849
  }
2672
3850
  }
3851
+ if (selected.includes('coolify')) {
3852
+ while (true) {
3853
+ clearTerminal();
3854
+ coolifyConfig = await guideCoolifyConnector(rl, secrets);
3855
+ if (coolifyConfig?.baseUrl) {
3856
+ await upsertCoolifyConfig(args.config, coolifyConfig);
3857
+ }
3858
+ const check = await runImmediateConnectorHealthCheck({
3859
+ rl,
3860
+ configPath: args.config,
3861
+ connector: 'coolify',
3862
+ secrets,
3863
+ });
3864
+ if (!check.retry)
3865
+ break;
3866
+ }
3867
+ }
2673
3868
  if (selected.includes('asc')) {
2674
3869
  while (true) {
2675
3870
  clearTerminal();
@@ -2684,6 +3879,21 @@ async function runConnectorSetupSteps({ rl, args, selected, healthByConnector, a
2684
3879
  break;
2685
3880
  }
2686
3881
  }
3882
+ for (const connector of selected.filter(isAccountSignalConnector)) {
3883
+ while (true) {
3884
+ clearTerminal();
3885
+ await guideAccountSignalConnector(rl, secrets, connector);
3886
+ await upsertAccountSignalConnectorConfig(args.config, connector);
3887
+ const check = await runImmediateConnectorHealthCheck({
3888
+ rl,
3889
+ configPath: args.config,
3890
+ connector,
3891
+ secrets,
3892
+ });
3893
+ if (!check.retry)
3894
+ break;
3895
+ }
3896
+ }
2687
3897
  const secretsFile = resolveSecretsFile();
2688
3898
  const wroteSecrets = Object.keys(secrets).length > 0;
2689
3899
  clearTerminal();
@@ -2695,30 +3905,48 @@ async function runConnectorSetupSteps({ rl, args, selected, healthByConnector, a
2695
3905
  process.stdout.write('\nNo new secrets were written.\n');
2696
3906
  }
2697
3907
  if (sentryAccounts.length > 0 && await upsertSentryAccountsConfig(args.config, sentryAccounts)) {
2698
- process.stdout.write(`Configured ${sentryAccounts.length} Sentry-compatible account(s) in ${args.config}.\n`);
3908
+ const readiness = await verifySentryAccountsConfig(args.config, sentryAccounts);
3909
+ if (readiness.ok) {
3910
+ process.stdout.write(`Configured ${sentryAccounts.length} Sentry-compatible account(s) in ${args.config}.\n`);
3911
+ }
3912
+ }
3913
+ if (coolifyConfig?.baseUrl && await upsertCoolifyConfig(args.config, coolifyConfig)) {
3914
+ process.stdout.write(`Configured Coolify monitoring for ${coolifyConfig.baseUrl} in ${args.config}.\n`);
2699
3915
  }
2700
3916
  const env = {
2701
3917
  ...process.env,
2702
3918
  ...secrets,
2703
3919
  };
2704
- const command = `node scripts/openclaw-growth-start.mjs --config ${quote(args.config)} --setup-only --connectors ${quote(selected.join(','))}`;
3920
+ const command = `${nodeRuntimeScriptCommand('openclaw-growth-start.mjs')} --config ${quote(args.config)} --setup-only --connectors ${quote(selected.join(','))} --only-connectors ${quote(selected.join(','))}`;
2705
3921
  let setupResult = await runSetupCommandWithProgress(command, env, selected, 'Testing connector setup...');
2706
3922
  let setupPayload = parseJsonFromStdout(setupResult.stdout);
3923
+ const postSetupBlockers = [];
2707
3924
  if (sentryAccounts.length > 0 && await upsertSentryAccountsConfig(args.config, sentryAccounts)) {
2708
- process.stdout.write(`Sentry-compatible account config is up to date in ${args.config}.\n`);
2709
- }
2710
- if (selected.includes('asc')) {
2711
- try {
2712
- const ascWebAuthChanged = await ensureAscWebAnalyticsAuth(rl, secrets);
2713
- if (ascWebAuthChanged) {
2714
- setupResult = await runSetupCommandWithProgress(command, env, selected, 'Retesting connector setup after ASC web analytics login...');
2715
- setupPayload = parseJsonFromStdout(setupResult.stdout);
2716
- }
3925
+ const readiness = await verifySentryAccountsConfig(args.config, sentryAccounts);
3926
+ if (readiness.ok) {
3927
+ process.stdout.write(`Sentry-compatible account config is up to date in ${args.config}.\n`);
2717
3928
  }
2718
- catch (error) {
2719
- process.stdout.write(`ASC web analytics still needs attention: ${error instanceof Error ? error.message : String(error)}\n`);
3929
+ else {
3930
+ postSetupBlockers.push({
3931
+ check: 'connection:sentry',
3932
+ detail: readiness.detail,
3933
+ remediation: 'Rerun Sentry/GlitchTip setup so the active config persists sources.sentry.enabled=true and sources.sentry.accounts[].',
3934
+ });
2720
3935
  }
2721
3936
  }
3937
+ if (coolifyConfig?.baseUrl && await upsertCoolifyConfig(args.config, coolifyConfig)) {
3938
+ process.stdout.write(`Coolify config is up to date in ${args.config}.\n`);
3939
+ }
3940
+ if (postSetupBlockers.length > 0) {
3941
+ setupPayload = {
3942
+ ...(setupPayload || {}),
3943
+ ok: false,
3944
+ blockers: [...(Array.isArray(setupPayload?.blockers) ? setupPayload.blockers : []), ...postSetupBlockers],
3945
+ };
3946
+ printSetupFailure({ result: { ...setupResult, ok: false, code: setupResult.code ?? 1 }, payload: setupPayload, command });
3947
+ process.exitCode = 1;
3948
+ return false;
3949
+ }
2722
3950
  if (setupResult.ok && setupPayload?.ok !== false) {
2723
3951
  printSetupSuccess(setupPayload);
2724
3952
  if (wroteSecrets) {
@@ -2747,15 +3975,18 @@ async function runConnectorSetupWizard(args) {
2747
3975
  try {
2748
3976
  clearTerminal();
2749
3977
  printConnectorIntro();
3978
+ await migrateRuntimeSourceCommandsFile(args.config);
2750
3979
  const healthByConnector = await withConnectorHealthLoading((onProgress) => getConnectorPickerHealth(args.config, onProgress));
2751
3980
  const existingFixes = connectorKeysNeedingAttention(healthByConnector);
2752
3981
  const requestedConnectors = args.connectors ? parseConnectorList(args.connectors) : [];
2753
3982
  const chosenConnectors = requestedConnectors.length > 0
2754
- ? orderConnectors([...new Set([...requestedConnectors, ...existingFixes])])
3983
+ ? orderConnectors(requestedConnectors)
2755
3984
  : await askConnectorSelectionWithHealth(rl, healthByConnector, existingFixes);
2756
- const selected = withMissingRequiredAnalyticsConnector(chosenConnectors);
3985
+ const selected = requestedConnectors.length > 0
3986
+ ? orderConnectors(chosenConnectors)
3987
+ : withMissingRequiredAnalyticsConnector(chosenConnectors);
2757
3988
  if (selected.length === 0) {
2758
- throw new Error('No supported connectors selected. Use analytics, github, revenuecat, sentry, asc, or all.');
3989
+ throw new Error(`No supported connectors selected. Use ${CONNECTOR_KEYS.join(', ')}, or all.`);
2759
3990
  }
2760
3991
  await runConnectorSetupSteps({ rl, args, selected, healthByConnector });
2761
3992
  }
@@ -2796,12 +4027,32 @@ async function askYesNo(rl, label, defaultYes = true) {
2796
4027
  }
2797
4028
  }
2798
4029
  }
4030
+ function truncateTableCell(value, width) {
4031
+ const text = String(value || '').replace(/\s+/g, ' ').trim();
4032
+ if (text.length <= width)
4033
+ return text.padEnd(width, ' ');
4034
+ return `${text.slice(0, Math.max(0, width - 3))}...`.padEnd(width, ' ');
4035
+ }
4036
+ function printAsciiTable(headers, rows, widths) {
4037
+ const border = `+${widths.map((width) => '-'.repeat(width + 2)).join('+')}+`;
4038
+ const renderRow = (cells) => `| ${cells.map((cell, index) => truncateTableCell(cell, widths[index])).join(' | ')} |`;
4039
+ process.stdout.write(`${border}\n`);
4040
+ process.stdout.write(`${renderRow(headers)}\n`);
4041
+ process.stdout.write(`${border}\n`);
4042
+ for (const row of rows) {
4043
+ process.stdout.write(`${renderRow(row)}\n`);
4044
+ }
4045
+ process.stdout.write(`${border}\n`);
4046
+ }
2799
4047
  function printCadencePlan(cadences) {
2800
4048
  process.stdout.write('\nDefault growth cadence:\n');
2801
- for (const cadence of cadences) {
2802
- const critical = cadence.criticalOnly ? 'critical only' : 'full review';
2803
- process.stdout.write(`- ${cadence.title} (${critical}): ${cadence.objective}\n`);
2804
- }
4049
+ printAsciiTable(['Cadence', 'Every', 'Mode', 'Primary focus', 'What it decides'], cadences.map((cadence) => [
4050
+ cadence.key,
4051
+ cadence.intervalMinutes ? `${cadence.intervalMinutes}m` : `${cadence.intervalDays}d`,
4052
+ cadence.criticalOnly ? 'critical only' : 'full review',
4053
+ Array.isArray(cadence.focusAreas) ? cadence.focusAreas.slice(0, 4).join(', ') : '',
4054
+ cadence.objective,
4055
+ ]), [12, 7, 13, 30, 42]);
2805
4056
  process.stdout.write('\n');
2806
4057
  }
2807
4058
  async function askToolUsage(rl) {
@@ -2828,18 +4079,72 @@ async function askToolUsage(rl) {
2828
4079
  ],
2829
4080
  });
2830
4081
  }
2831
- async function askCadencePlan(rl) {
2832
- const cadences = DEFAULT_CADENCE_PLAN.map((cadence) => ({ ...cadence }));
4082
+ async function askSchedulePreset(rl) {
4083
+ return await askMenuChoice(rl, {
4084
+ title: 'Schedule preset',
4085
+ subtitle: 'Use Up/Down to move, Enter to continue, or press 1-3.',
4086
+ defaultValue: 'recommended',
4087
+ options: [
4088
+ {
4089
+ value: 'recommended',
4090
+ label: 'Recommended',
4091
+ detail: 'OpenClaw/Hermes wake every 30m; Sentry/GlitchTip/Coolify healthcheck runs every 90m; daily and larger reviews stay on cadence.',
4092
+ },
4093
+ {
4094
+ value: 'quiet',
4095
+ label: 'Quiet',
4096
+ detail: 'OpenClaw/Hermes wake hourly; healthcheck runs every 6h; deep reviews unchanged.',
4097
+ },
4098
+ {
4099
+ value: 'manual',
4100
+ label: 'Manual',
4101
+ detail: 'Enter runner, connector-health, cron, and cadence intervals yourself.',
4102
+ },
4103
+ ],
4104
+ });
4105
+ }
4106
+ async function askCadencePlan(rl, existingCadences = []) {
4107
+ const existingByKey = new Map((Array.isArray(existingCadences) ? existingCadences : [])
4108
+ .filter((cadence) => cadence?.key)
4109
+ .map((cadence) => [String(cadence.key), cadence]));
4110
+ const cadences = DEFAULT_CADENCE_PLAN.map((cadence) => ({
4111
+ ...cadence,
4112
+ ...(existingByKey.get(cadence.key) || {}),
4113
+ }));
2833
4114
  printCadencePlan(cadences);
2834
- const customize = await askYesNo(rl, 'Use this default cadence plan? Answer no to edit daily/weekly/monthly/3-month/6-month/1-year instructions.', true);
2835
- if (customize)
4115
+ const selectedCadences = await askMultiChoice(rl, {
4116
+ title: 'Scheduled review cadences',
4117
+ subtitle: 'Use Up/Down to move, Space to toggle cadences, A to toggle all, Enter to continue.',
4118
+ defaultValues: cadences.filter((cadence) => cadence.enabled !== false).map((cadence) => cadence.key),
4119
+ minSelections: 1,
4120
+ options: cadences.map((cadence) => ({
4121
+ value: cadence.key,
4122
+ label: cadence.title,
4123
+ detail: `${cadence.intervalMinutes ? `${cadence.intervalMinutes}m` : `${cadence.intervalDays}d`}, ${cadence.criticalOnly ? 'critical only' : 'full review'} - ${cadence.objective}`,
4124
+ })),
4125
+ });
4126
+ const selected = new Set(selectedCadences);
4127
+ cadences.forEach((cadence) => {
4128
+ cadence.enabled = selected.has(cadence.key);
4129
+ });
4130
+ const customize = await askYesNo(rl, 'Customize objectives, instructions, focus areas, or source priorities for enabled cadences?', false);
4131
+ if (!customize)
2836
4132
  return cadences;
2837
4133
  for (const cadence of cadences) {
2838
- process.stdout.write(`\n${cadence.title}\n`);
2839
- const enabled = await askYesNo(rl, `Enable ${cadence.key}?`, true);
2840
- cadence.enabled = enabled;
2841
- if (!enabled)
4134
+ if (cadence.enabled === false)
2842
4135
  continue;
4136
+ process.stdout.write(`\n${cadence.title}\n`);
4137
+ const intervalDefault = cadence.intervalMinutes ? `${cadence.intervalMinutes}m` : `${cadence.intervalDays || 1}d`;
4138
+ const intervalRaw = await ask(rl, `${cadence.key} interval (for example 90m, 1d, 7d)`, intervalDefault);
4139
+ const intervalMatch = String(intervalRaw || intervalDefault).trim().match(/^(\d+)\s*([md])$/i);
4140
+ if (intervalMatch?.[2]?.toLowerCase() === 'm') {
4141
+ cadence.intervalMinutes = Number.parseInt(intervalMatch[1], 10) || cadence.intervalMinutes || 90;
4142
+ delete cadence.intervalDays;
4143
+ }
4144
+ else if (intervalMatch?.[2]?.toLowerCase() === 'd') {
4145
+ cadence.intervalDays = Number.parseInt(intervalMatch[1], 10) || cadence.intervalDays || 1;
4146
+ delete cadence.intervalMinutes;
4147
+ }
2843
4148
  cadence.objective = await ask(rl, `${cadence.key} objective`, cadence.objective);
2844
4149
  cadence.instructions = await ask(rl, `${cadence.key} instructions`, cadence.instructions);
2845
4150
  const focusAreas = await ask(rl, `${cadence.key} focus areas (comma-separated)`, cadence.focusAreas.join(','));
@@ -2884,7 +4189,7 @@ function printWizardHeader() {
2884
4189
  process.stdout.write('OpenClaw Growth Engineer - Setup Wizard\n');
2885
4190
  process.stdout.write('This wizard can configure connector secrets. Normal config is written to config JSON; API keys stay in the local chmod 600 secrets file.\n\n');
2886
4191
  }
2887
- async function buildDefaultWizardConfig() {
4192
+ async function buildDefaultWizardConfig(configPath = null) {
2888
4193
  return {
2889
4194
  version: 7,
2890
4195
  generatedAt: new Date().toISOString(),
@@ -2900,17 +4205,43 @@ async function buildDefaultWizardConfig() {
2900
4205
  analytics: {
2901
4206
  enabled: true,
2902
4207
  mode: 'command',
2903
- command: getDefaultSourceCommand('analytics'),
4208
+ command: getWizardDefaultSourceCommand('analytics'),
2904
4209
  },
2905
4210
  revenuecat: {
2906
- enabled: false,
4211
+ enabled: true,
4212
+ mode: 'command',
4213
+ command: getWizardDefaultSourceCommand('revenuecat'),
4214
+ },
4215
+ paddle: {
4216
+ enabled: true,
4217
+ mode: 'command',
4218
+ command: getWizardDefaultSourceCommand('paddle'),
4219
+ environment: 'live',
4220
+ },
4221
+ seo: {
4222
+ enabled: true,
2907
4223
  mode: 'command',
2908
- command: getDefaultSourceCommand('revenuecat'),
4224
+ command: getWizardDefaultSourceCommand('seo'),
4225
+ siteUrl: process.env.GSC_SITE_URL || '',
4226
+ paidProvider: {
4227
+ dataforseo: {
4228
+ enabled: false,
4229
+ confirmPaid: false,
4230
+ maxPaidRequests: 1,
4231
+ },
4232
+ },
2909
4233
  },
2910
4234
  sentry: {
2911
4235
  enabled: true,
2912
4236
  mode: 'command',
2913
- command: getDefaultSourceCommand('sentry'),
4237
+ command: normalizeWizardSourceCommand('sentry', {}, configPath),
4238
+ },
4239
+ coolify: {
4240
+ enabled: true,
4241
+ mode: 'command',
4242
+ command: normalizeWizardSourceCommand('coolify', {}, configPath),
4243
+ baseUrl: process.env.COOLIFY_BASE_URL || 'https://coolify.wotaso.com',
4244
+ tokenEnv: 'COOLIFY_API_TOKEN',
2914
4245
  },
2915
4246
  feedback: {
2916
4247
  enabled: true,
@@ -2920,7 +4251,7 @@ async function buildDefaultWizardConfig() {
2920
4251
  initialLookback: '30d',
2921
4252
  },
2922
4253
  extra: [
2923
- buildExtraSourceConfig('asc-cli', { enabled: false, mode: 'command', command: getDefaultSourceCommand('asc') }),
4254
+ buildExtraSourceConfig('asc-cli', { enabled: true, mode: 'command', command: getWizardDefaultSourceCommand('asc') }),
2924
4255
  ],
2925
4256
  },
2926
4257
  schedule: {
@@ -2931,11 +4262,13 @@ async function buildDefaultWizardConfig() {
2931
4262
  cadences: DEFAULT_CADENCE_PLAN.map((cadence) => ({ ...cadence })),
2932
4263
  },
2933
4264
  actions: {
2934
- autoCreateIssues: false,
4265
+ autoCreateIssues: true,
2935
4266
  autoCreatePullRequests: false,
2936
4267
  autoCreateWhenGitHubWriteAccess: true,
2937
4268
  disableAutoCreateGitHubArtifacts: false,
2938
4269
  mode: 'issue',
4270
+ outputDestinations: ['openclaw_chat', 'github_issue'],
4271
+ productionErrorMode: 'issue',
2939
4272
  usageMode: 'production_autopilot',
2940
4273
  draftPullRequests: true,
2941
4274
  proposalBranchPrefix: 'openclaw/proposals',
@@ -2947,9 +4280,9 @@ async function buildDefaultWizardConfig() {
2947
4280
  jsonPath: '.openclaw/chat/latest.json',
2948
4281
  },
2949
4282
  github: {
2950
- enabled: false,
4283
+ enabled: true,
2951
4284
  mode: 'issue',
2952
- autoCreate: false,
4285
+ autoCreate: true,
2953
4286
  draftPullRequests: true,
2954
4287
  proposalBranchPrefix: 'openclaw/proposals',
2955
4288
  },
@@ -2963,13 +4296,19 @@ async function buildDefaultWizardConfig() {
2963
4296
  method: 'POST',
2964
4297
  headers: {},
2965
4298
  },
4299
+ command: {
4300
+ enabled: false,
4301
+ label: 'command',
4302
+ command: '',
4303
+ },
2966
4304
  discord: {
2967
4305
  enabled: false,
2968
- command: 'node scripts/discord-openclaw-bridge.mjs send --stdin',
4306
+ label: 'discord',
4307
+ command: '',
2969
4308
  },
2970
4309
  },
2971
4310
  charting: {
2972
- enabled: false,
4311
+ enabled: true,
2973
4312
  command: null,
2974
4313
  },
2975
4314
  notifications: {
@@ -2996,6 +4335,21 @@ async function buildDefaultWizardConfig() {
2996
4335
  ],
2997
4336
  },
2998
4337
  },
4338
+ automation: {
4339
+ openclawCron: {
4340
+ enabled: true,
4341
+ mode: 'main',
4342
+ schedule: '*/30 * * * *',
4343
+ timezone: process.env.TZ || 'UTC',
4344
+ name: 'OpenClaw Growth Engineer scheduler',
4345
+ delivery: {
4346
+ enabled: true,
4347
+ mode: 'announce',
4348
+ channel: 'last',
4349
+ to: '',
4350
+ },
4351
+ },
4352
+ },
2999
4353
  secrets: {
3000
4354
  githubTokenEnv: 'GITHUB_TOKEN',
3001
4355
  githubTokenRef: { source: 'env', provider: 'default', id: 'GITHUB_TOKEN' },
@@ -3003,27 +4357,63 @@ async function buildDefaultWizardConfig() {
3003
4357
  analyticsTokenRef: { source: 'env', provider: 'default', id: 'ANALYTICSCLI_ACCESS_TOKEN' },
3004
4358
  revenuecatTokenEnv: 'REVENUECAT_API_KEY',
3005
4359
  revenuecatTokenRef: { source: 'env', provider: 'default', id: 'REVENUECAT_API_KEY' },
4360
+ paddleTokenEnv: 'PADDLE_API_KEY',
4361
+ paddleTokenRef: { source: 'env', provider: 'default', id: 'PADDLE_API_KEY' },
4362
+ gscTokenEnv: 'GOOGLE_SEARCH_CONSOLE_ACCESS_TOKEN',
4363
+ gscTokenRef: { source: 'env', provider: 'default', id: 'GOOGLE_SEARCH_CONSOLE_ACCESS_TOKEN' },
4364
+ dataforseoLoginEnv: 'DATAFORSEO_LOGIN',
4365
+ dataforseoLoginRef: { source: 'env', provider: 'default', id: 'DATAFORSEO_LOGIN' },
4366
+ dataforseoPasswordEnv: 'DATAFORSEO_PASSWORD',
4367
+ dataforseoPasswordRef: { source: 'env', provider: 'default', id: 'DATAFORSEO_PASSWORD' },
3006
4368
  sentryTokenEnv: 'SENTRY_AUTH_TOKEN',
3007
4369
  sentryTokenRef: { source: 'env', provider: 'default', id: 'SENTRY_AUTH_TOKEN' },
4370
+ coolifyTokenEnv: 'COOLIFY_API_TOKEN',
4371
+ coolifyTokenRef: { source: 'env', provider: 'default', id: 'COOLIFY_API_TOKEN' },
3008
4372
  },
3009
4373
  };
3010
4374
  }
3011
- function buildRecommendedSourceConfig() {
4375
+ function buildRecommendedSourceConfig(configPath = null) {
3012
4376
  return {
3013
4377
  analytics: {
3014
4378
  enabled: true,
3015
4379
  mode: 'command',
3016
- command: getDefaultSourceCommand('analytics'),
4380
+ command: getWizardDefaultSourceCommand('analytics'),
3017
4381
  },
3018
4382
  revenuecat: {
3019
- enabled: false,
4383
+ enabled: true,
4384
+ mode: 'command',
4385
+ command: getWizardDefaultSourceCommand('revenuecat'),
4386
+ },
4387
+ paddle: {
4388
+ enabled: true,
3020
4389
  mode: 'command',
3021
- command: getDefaultSourceCommand('revenuecat'),
4390
+ command: getWizardDefaultSourceCommand('paddle'),
4391
+ environment: 'live',
4392
+ },
4393
+ seo: {
4394
+ enabled: true,
4395
+ mode: 'command',
4396
+ command: getWizardDefaultSourceCommand('seo'),
4397
+ siteUrl: process.env.GSC_SITE_URL || '',
4398
+ paidProvider: {
4399
+ dataforseo: {
4400
+ enabled: false,
4401
+ confirmPaid: false,
4402
+ maxPaidRequests: 1,
4403
+ },
4404
+ },
3022
4405
  },
3023
4406
  sentry: {
3024
4407
  enabled: true,
3025
4408
  mode: 'command',
3026
- command: getDefaultSourceCommand('sentry'),
4409
+ command: normalizeWizardSourceCommand('sentry', {}, configPath),
4410
+ },
4411
+ coolify: {
4412
+ enabled: true,
4413
+ mode: 'command',
4414
+ command: normalizeWizardSourceCommand('coolify', {}, configPath),
4415
+ baseUrl: process.env.COOLIFY_BASE_URL || 'https://coolify.wotaso.com',
4416
+ tokenEnv: 'COOLIFY_API_TOKEN',
3027
4417
  },
3028
4418
  feedback: {
3029
4419
  enabled: true,
@@ -3033,7 +4423,7 @@ function buildRecommendedSourceConfig() {
3033
4423
  initialLookback: '30d',
3034
4424
  },
3035
4425
  extra: [
3036
- buildExtraSourceConfig('asc-cli', { enabled: false, mode: 'command', command: getDefaultSourceCommand('asc') }),
4426
+ buildExtraSourceConfig('asc-cli', { enabled: true, mode: 'command', command: getWizardDefaultSourceCommand('asc') }),
3037
4427
  ],
3038
4428
  };
3039
4429
  }
@@ -3042,63 +4432,132 @@ function getInputChannelInitialSelection(config) {
3042
4432
  const extraSources = Array.isArray(sources.extra) ? sources.extra : [];
3043
4433
  const selected = new Set();
3044
4434
  const hasExplicitSources = Boolean(config?.sources);
4435
+ if (!hasExplicitSources)
4436
+ return orderConnectors([...CONNECTOR_KEYS]);
3045
4437
  if (!hasExplicitSources || sources.analytics?.enabled !== false)
3046
4438
  selected.add('analytics');
3047
4439
  if (sources.revenuecat?.enabled === true || isConnectorLocallyConfigured('revenuecat'))
3048
4440
  selected.add('revenuecat');
4441
+ if (sources.paddle?.enabled === true || isConnectorLocallyConfigured('paddle'))
4442
+ selected.add('paddle');
4443
+ if (sources.seo?.enabled === true || isConnectorLocallyConfigured('seo'))
4444
+ selected.add('seo');
3049
4445
  if (!hasExplicitSources || sources.sentry?.enabled !== false)
3050
4446
  selected.add('sentry');
4447
+ if (sources.coolify?.enabled === true || isConnectorLocallyConfigured('coolify'))
4448
+ selected.add('coolify');
3051
4449
  if (extraSources.some((source) => ['asc', 'asc-cli', 'app-store-connect', 'app_store_connect'].includes(String(source?.service || source?.key || '').toLowerCase()) &&
3052
4450
  source?.enabled !== false) ||
3053
4451
  isConnectorLocallyConfigured('asc')) {
3054
4452
  selected.add('asc');
3055
4453
  }
3056
- if (config?.deliveries?.github?.enabled ||
3057
- config?.actions?.autoCreateIssues ||
3058
- config?.actions?.autoCreatePullRequests ||
3059
- isConnectorLocallyConfigured('github')) {
3060
- selected.add('github');
4454
+ for (const key of ACCOUNT_SIGNAL_CONNECTOR_KEYS) {
4455
+ if (extraSources.some((source) => String(source?.key || source?.service || '').toLowerCase() === key && source?.enabled !== false) ||
4456
+ isConnectorLocallyConfigured(key)) {
4457
+ selected.add(key);
4458
+ }
3061
4459
  }
4460
+ selected.add('github');
4461
+ if (selected.size === 0)
4462
+ return orderConnectors([...CONNECTOR_KEYS]);
3062
4463
  return orderConnectors([...selected]);
3063
4464
  }
3064
- function buildSourceConfigFromInputChannels(selectedConnectors, existingSources = {}) {
4465
+ function buildSourceConfigFromInputChannels(selectedConnectors, existingSources = {}, configPath = null) {
3065
4466
  const selected = new Set(selectedConnectors);
3066
- const recommended = buildRecommendedSourceConfig();
3067
- const existingExtra = Array.isArray(existingSources.extra) ? existingSources.extra : [];
4467
+ const recommended = buildRecommendedSourceConfig(configPath);
4468
+ const migratedSources = migrateRuntimeSourceCommands({ sources: existingSources }, configPath).sources || {};
4469
+ const existingExtra = Array.isArray(migratedSources.extra) ? migratedSources.extra : [];
3068
4470
  const ascSource = existingExtra.find((source) => ['asc', 'asc-cli', 'app-store-connect', 'app_store_connect'].includes(String(source?.service || source?.key || '').toLowerCase()));
3069
- const nonAscExtra = existingExtra.filter((source) => source !== ascSource);
4471
+ const managedAccountKeys = new Set(ACCOUNT_SIGNAL_CONNECTOR_KEYS);
4472
+ const accountSourceByKey = new Map(existingExtra
4473
+ .filter((source) => managedAccountKeys.has(String(source?.key || source?.service || '').toLowerCase()))
4474
+ .map((source) => [String(source?.key || source?.service || '').toLowerCase(), source]));
4475
+ const nonAscExtra = existingExtra.filter((source) => {
4476
+ if (source === ascSource)
4477
+ return false;
4478
+ return !managedAccountKeys.has(String(source?.key || source?.service || '').toLowerCase());
4479
+ });
4480
+ const accountExtra = ACCOUNT_SIGNAL_CONNECTOR_KEYS.map((key) => buildAccountSignalExtraSourceConfig(key, accountSourceByKey.get(key) || { enabled: selected.has(key) })).map((source) => ({ ...source, enabled: selected.has(source.key) }));
3070
4481
  return {
3071
4482
  ...recommended,
3072
- ...existingSources,
4483
+ ...migratedSources,
3073
4484
  analytics: {
3074
4485
  ...recommended.analytics,
3075
- ...(existingSources.analytics || {}),
4486
+ ...(migratedSources.analytics || {}),
4487
+ command: normalizeWizardSourceCommand('analytics', {
4488
+ ...recommended.analytics,
4489
+ ...(migratedSources.analytics || {}),
4490
+ }, configPath),
3076
4491
  enabled: selected.has('analytics'),
3077
4492
  },
3078
4493
  revenuecat: {
3079
4494
  ...recommended.revenuecat,
3080
- ...(existingSources.revenuecat || {}),
4495
+ ...(migratedSources.revenuecat || {}),
4496
+ command: normalizeWizardSourceCommand('revenuecat', {
4497
+ ...recommended.revenuecat,
4498
+ ...(migratedSources.revenuecat || {}),
4499
+ }, configPath),
3081
4500
  enabled: selected.has('revenuecat'),
3082
4501
  },
4502
+ paddle: {
4503
+ ...recommended.paddle,
4504
+ ...(migratedSources.paddle || {}),
4505
+ command: normalizeWizardSourceCommand('paddle', {
4506
+ ...recommended.paddle,
4507
+ ...(migratedSources.paddle || {}),
4508
+ }, configPath),
4509
+ enabled: selected.has('paddle'),
4510
+ },
4511
+ seo: {
4512
+ ...recommended.seo,
4513
+ ...(migratedSources.seo || {}),
4514
+ command: normalizeWizardSourceCommand('seo', {
4515
+ ...recommended.seo,
4516
+ ...(migratedSources.seo || {}),
4517
+ }, configPath),
4518
+ enabled: selected.has('seo'),
4519
+ },
3083
4520
  sentry: {
3084
4521
  ...recommended.sentry,
3085
- ...(existingSources.sentry || {}),
4522
+ ...(migratedSources.sentry || {}),
4523
+ command: normalizeWizardSourceCommand('sentry', {
4524
+ ...recommended.sentry,
4525
+ ...(migratedSources.sentry || {}),
4526
+ }, configPath),
3086
4527
  enabled: selected.has('sentry'),
3087
4528
  },
4529
+ coolify: {
4530
+ ...recommended.coolify,
4531
+ ...(migratedSources.coolify || {}),
4532
+ command: normalizeWizardSourceCommand('coolify', {
4533
+ ...recommended.coolify,
4534
+ ...(migratedSources.coolify || {}),
4535
+ }, configPath),
4536
+ enabled: selected.has('coolify'),
4537
+ },
3088
4538
  feedback: {
3089
4539
  ...recommended.feedback,
3090
- ...(existingSources.feedback || {}),
4540
+ ...(migratedSources.feedback || {}),
3091
4541
  enabled: selected.has('analytics'),
3092
4542
  },
3093
4543
  extra: [
3094
4544
  ...nonAscExtra,
4545
+ ...accountExtra,
3095
4546
  {
3096
4547
  ...buildExtraSourceConfig('asc-cli', {
3097
4548
  enabled: selected.has('asc'),
3098
4549
  mode: 'command',
3099
- command: getDefaultSourceCommand('asc'),
4550
+ command: getWizardDefaultSourceCommand('asc'),
3100
4551
  }),
3101
4552
  ...(ascSource || {}),
4553
+ command: normalizeWizardSourceCommand('asc', {
4554
+ ...buildExtraSourceConfig('asc-cli', {
4555
+ enabled: selected.has('asc'),
4556
+ mode: 'command',
4557
+ command: getWizardDefaultSourceCommand('asc'),
4558
+ }),
4559
+ ...(ascSource || {}),
4560
+ }, configPath),
3102
4561
  enabled: selected.has('asc'),
3103
4562
  },
3104
4563
  ],
@@ -3107,8 +4566,8 @@ function buildSourceConfigFromInputChannels(selectedConnectors, existingSources
3107
4566
  async function loadEditableConfig(configPath) {
3108
4567
  const existing = await readJsonIfPresent(configPath).catch(() => null);
3109
4568
  if (existing && typeof existing === 'object')
3110
- return existing;
3111
- return await buildDefaultWizardConfig();
4569
+ return migrateRuntimeSourceCommands(existing, configPath);
4570
+ return await buildDefaultWizardConfig(configPath);
3112
4571
  }
3113
4572
  function mergeNotificationChannels(baseChannels, extraChannels) {
3114
4573
  const channels = [];
@@ -3143,9 +4602,9 @@ async function askNotificationChannels(rl, config) {
3143
4602
  const urlEnv = await ask(rl, 'Webhook URL env var', config?.deliveries?.webhook?.urlEnv || 'OPENCLAW_WEBHOOK_URL');
3144
4603
  channels.push({ type: 'webhook', enabled: true, urlEnv, method: 'POST', headers: {} });
3145
4604
  }
3146
- const commandDefault = Boolean(config?.deliveries?.discord?.enabled);
4605
+ const commandDefault = Boolean(config?.deliveries?.command?.enabled || config?.deliveries?.discord?.enabled);
3147
4606
  if (await askYesNo(rl, 'Send summaries and connector-health alerts through a local command channel?', commandDefault)) {
3148
- const command = await ask(rl, 'Command that receives the message on stdin', config?.deliveries?.discord?.command || 'node scripts/discord-openclaw-bridge.mjs send --stdin');
4607
+ const command = await ask(rl, 'Command that receives the message on stdin', config?.deliveries?.command?.command || config?.deliveries?.discord?.command || '');
3149
4608
  channels.push({ type: 'command', enabled: true, label: 'command', command });
3150
4609
  }
3151
4610
  return channels;
@@ -3156,11 +4615,25 @@ async function askOutputConfig(rl, config) {
3156
4615
  'GitHub issues or draft PRs are optional and only run when a token plus an inferred repo are available.',
3157
4616
  ]);
3158
4617
  const currentMode = config?.actions?.mode || config?.deliveries?.github?.mode || 'issue';
3159
- const currentAutoCreate = Boolean(config?.actions?.autoCreateIssues || config?.actions?.autoCreatePullRequests || config?.deliveries?.github?.autoCreate);
3160
- const outputChoice = await askMenuChoice(rl, {
3161
- title: 'Output mode',
3162
- subtitle: 'Use Up/Down to move, Enter to continue, or press 1-3.',
3163
- defaultValue: currentAutoCreate ? (currentMode === 'pull_request' ? 'pull_request' : 'issue') : 'chat',
4618
+ const configuredDestinations = Array.isArray(config?.actions?.outputDestinations)
4619
+ ? config.actions.outputDestinations
4620
+ : [];
4621
+ const currentAutoCreateIssue = Boolean(config?.actions?.autoCreateIssues ||
4622
+ configuredDestinations.includes('github_issue') ||
4623
+ (config?.deliveries?.github?.autoCreate && currentMode !== 'pull_request'));
4624
+ const currentAutoCreatePullRequest = Boolean(config?.actions?.autoCreatePullRequests ||
4625
+ configuredDestinations.includes('github_pull_request') ||
4626
+ (config?.deliveries?.github?.autoCreate && currentMode === 'pull_request'));
4627
+ const outputChoices = await askMultiChoice(rl, {
4628
+ title: 'Output destinations',
4629
+ subtitle: 'Use Up/Down to move, Space to toggle outputs, A to toggle all optional outputs, Enter to continue.',
4630
+ defaultValues: [
4631
+ 'chat',
4632
+ ...(currentAutoCreateIssue ? ['issue'] : []),
4633
+ ...(currentAutoCreatePullRequest ? ['pull_request'] : []),
4634
+ ],
4635
+ requiredValues: ['chat'],
4636
+ minSelections: 1,
3164
4637
  options: [
3165
4638
  {
3166
4639
  value: 'chat',
@@ -3179,9 +4652,35 @@ async function askOutputConfig(rl, config) {
3179
4652
  },
3180
4653
  ],
3181
4654
  });
3182
- const summaryOnly = outputChoice === 'chat';
3183
- const mode = outputChoice === 'pull_request' ? 'pull_request' : 'issue';
3184
- const autoCreate = !summaryOnly;
4655
+ const wantsIssue = outputChoices.includes('issue');
4656
+ const wantsPullRequest = outputChoices.includes('pull_request');
4657
+ const productionErrorMode = await askMenuChoice(rl, {
4658
+ title: 'Production error handling',
4659
+ subtitle: 'What should happen when the 90-minute healthcheck confirms a production Sentry/GlitchTip/Coolify issue?',
4660
+ defaultValue: config?.actions?.productionErrorMode || (wantsPullRequest ? 'pull_request' : wantsIssue ? 'issue' : 'alert'),
4661
+ options: [
4662
+ {
4663
+ value: 'alert',
4664
+ label: 'Alert only',
4665
+ detail: 'Send the short alert/handoff; do not auto-create GitHub artifacts for production errors.',
4666
+ },
4667
+ {
4668
+ value: 'issue',
4669
+ label: 'GitHub issue',
4670
+ detail: 'Create a GitHub issue with the production evidence and suggested investigation when access allows it.',
4671
+ },
4672
+ {
4673
+ value: 'pull_request',
4674
+ label: 'Draft PR',
4675
+ detail: 'Create a draft PR proposal for implementation-ready production fixes when access allows it.',
4676
+ },
4677
+ ],
4678
+ });
4679
+ const effectiveWantsIssue = wantsIssue || productionErrorMode === 'issue';
4680
+ const effectiveWantsPullRequest = wantsPullRequest || productionErrorMode === 'pull_request';
4681
+ const summaryOnly = !effectiveWantsIssue && !effectiveWantsPullRequest;
4682
+ const mode = effectiveWantsPullRequest ? 'pull_request' : 'issue';
4683
+ const autoCreate = effectiveWantsIssue || effectiveWantsPullRequest;
3185
4684
  if (!summaryOnly) {
3186
4685
  process.stdout.write('GitHub repo scope is not pinned by the wizard; OpenClaw/Hermes will infer it from OPENCLAW_GITHUB_REPO, the local git remote, or runtime context when creating issues/PRs.\n');
3187
4686
  }
@@ -3198,8 +4697,14 @@ async function askOutputConfig(rl, config) {
3198
4697
  config.actions = {
3199
4698
  ...(config.actions || {}),
3200
4699
  mode,
3201
- autoCreateIssues: mode === 'issue' && autoCreate,
3202
- autoCreatePullRequests: mode === 'pull_request' && autoCreate,
4700
+ outputDestinations: [
4701
+ 'openclaw_chat',
4702
+ ...(effectiveWantsIssue ? ['github_issue'] : []),
4703
+ ...(effectiveWantsPullRequest ? ['github_pull_request'] : []),
4704
+ ],
4705
+ productionErrorMode,
4706
+ autoCreateIssues: effectiveWantsIssue,
4707
+ autoCreatePullRequests: effectiveWantsPullRequest,
3203
4708
  autoCreateWhenGitHubWriteAccess: config.actions?.autoCreateWhenGitHubWriteAccess !== false,
3204
4709
  disableAutoCreateGitHubArtifacts: config.actions?.disableAutoCreateGitHubArtifacts === true,
3205
4710
  draftPullRequests: true,
@@ -3217,6 +4722,10 @@ async function askOutputConfig(rl, config) {
3217
4722
  ...(config.deliveries?.github || {}),
3218
4723
  enabled: !summaryOnly,
3219
4724
  mode,
4725
+ modes: [
4726
+ ...(effectiveWantsIssue ? ['issue'] : []),
4727
+ ...(effectiveWantsPullRequest ? ['pull_request'] : []),
4728
+ ],
3220
4729
  autoCreate,
3221
4730
  draftPullRequests: true,
3222
4731
  proposalBranchPrefix: config?.actions?.proposalBranchPrefix || 'openclaw/proposals',
@@ -3233,10 +4742,17 @@ async function askOutputConfig(rl, config) {
3233
4742
  method: 'POST',
3234
4743
  headers: config.deliveries?.webhook?.headers || {},
3235
4744
  },
4745
+ command: {
4746
+ ...(config.deliveries?.command || {}),
4747
+ enabled: channels.some((channel) => channel.type === 'command'),
4748
+ label: channels.find((channel) => channel.type === 'command')?.label || config.deliveries?.command?.label || 'command',
4749
+ command: channels.find((channel) => channel.type === 'command')?.command || config.deliveries?.command?.command || '',
4750
+ },
3236
4751
  discord: {
3237
4752
  ...(config.deliveries?.discord || {}),
3238
- enabled: channels.some((channel) => channel.type === 'command'),
3239
- command: channels.find((channel) => channel.type === 'command')?.command || config.deliveries?.discord?.command || 'node scripts/discord-openclaw-bridge.mjs send --stdin',
4753
+ enabled: Boolean(config.deliveries?.discord?.enabled),
4754
+ label: config.deliveries?.discord?.label || 'discord',
4755
+ command: config.deliveries?.discord?.command || '',
3240
4756
  },
3241
4757
  };
3242
4758
  config.notifications = {
@@ -3278,7 +4794,7 @@ async function askGitHubArtifactDetails(rl, config) {
3278
4794
  if (!customize) {
3279
4795
  config.charting = {
3280
4796
  ...(config.charting || {}),
3281
- enabled: config.charting?.enabled === true,
4797
+ enabled: config.charting?.enabled !== false,
3282
4798
  command: config.charting?.command || null,
3283
4799
  };
3284
4800
  return config;
@@ -3304,12 +4820,37 @@ async function askIntervalConfig(rl, config) {
3304
4820
  printSection('Schedule and analysis depth', [
3305
4821
  'The runner wakes up often, but larger reviews only run on their daily/weekly/monthly cadence.',
3306
4822
  'Connector health checks are separate and default to every 6 hours.',
4823
+ 'On OpenClaw or Hermes VPS installs, the agent scheduler should wake Growth Engineer; heartbeat stays as a fallback checklist.',
3307
4824
  ]);
3308
4825
  const currentSchedule = config?.schedule || {};
4826
+ const currentAutomation = getAutomationConfig(config);
3309
4827
  const usageMode = await askToolUsage(rl);
3310
- const intervalMinutes = Number.parseInt(await ask(rl, 'Growth runner wake-up interval in minutes', String(currentSchedule.intervalMinutes || DEFAULT_GROWTH_INTERVAL_MINUTES)), 10) || DEFAULT_GROWTH_INTERVAL_MINUTES;
3311
- const connectorHealthCheckIntervalMinutes = Number.parseInt(await ask(rl, 'Connector health check interval in minutes', String(currentSchedule.connectorHealthCheckIntervalMinutes || DEFAULT_CONNECTOR_HEALTH_INTERVAL_MINUTES)), 10) || DEFAULT_CONNECTOR_HEALTH_INTERVAL_MINUTES;
3312
- const cadences = await askCadencePlan(rl);
4828
+ const schedulePreset = await askSchedulePreset(rl);
4829
+ const recommendedRunnerInterval = schedulePreset === 'quiet' ? 360 : 90;
4830
+ const recommendedConnectorHealthInterval = schedulePreset === 'quiet' ? 360 : DEFAULT_CONNECTOR_HEALTH_INTERVAL_MINUTES;
4831
+ const recommendedOpenClawCronSchedule = schedulePreset === 'quiet' ? '0 * * * *' : '*/30 * * * *';
4832
+ const intervalMinutes = schedulePreset === 'manual'
4833
+ ? Number.parseInt(await ask(rl, 'Growth runner fallback loop interval in minutes', String(currentSchedule.intervalMinutes || recommendedRunnerInterval)), 10) || recommendedRunnerInterval
4834
+ : recommendedRunnerInterval;
4835
+ const connectorHealthCheckIntervalMinutes = schedulePreset === 'manual'
4836
+ ? Number.parseInt(await ask(rl, 'Connector credentials health check interval in minutes', String(currentSchedule.connectorHealthCheckIntervalMinutes || recommendedConnectorHealthInterval)), 10) || recommendedConnectorHealthInterval
4837
+ : recommendedConnectorHealthInterval;
4838
+ const cadences = await askCadencePlan(rl, currentSchedule.cadences);
4839
+ const enableOpenClawCron = await askYesNo(rl, 'Install an OpenClaw Gateway cron job to wake Growth Engineer on this VPS?', currentAutomation.openclawCron.enabled !== false);
4840
+ const openclawCronSchedule = enableOpenClawCron
4841
+ ? schedulePreset === 'manual'
4842
+ ? await ask(rl, 'OpenClaw cron expression for scheduler wakeups', currentAutomation.openclawCron.schedule || recommendedOpenClawCronSchedule)
4843
+ : recommendedOpenClawCronSchedule
4844
+ : currentAutomation.openclawCron.schedule;
4845
+ const openclawCronTimezone = enableOpenClawCron
4846
+ ? await ask(rl, 'OpenClaw cron timezone', currentAutomation.openclawCron.timezone || process.env.TZ || 'UTC')
4847
+ : currentAutomation.openclawCron.timezone;
4848
+ const enableHermesCron = await askYesNo(rl, 'Install a Hermes cron job when Hermes is available on this host?', currentAutomation.hermesCron.enabled !== false);
4849
+ const hermesCronSchedule = enableHermesCron
4850
+ ? schedulePreset === 'manual'
4851
+ ? await ask(rl, 'Hermes cron expression for scheduler wakeups', currentAutomation.hermesCron.schedule || openclawCronSchedule || recommendedOpenClawCronSchedule)
4852
+ : openclawCronSchedule || recommendedOpenClawCronSchedule
4853
+ : currentAutomation.hermesCron.schedule;
3313
4854
  config.schedule = {
3314
4855
  ...currentSchedule,
3315
4856
  intervalMinutes,
@@ -3322,6 +4863,32 @@ async function askIntervalConfig(rl, config) {
3322
4863
  ...(config.actions || {}),
3323
4864
  usageMode,
3324
4865
  };
4866
+ config.automation = {
4867
+ ...(config.automation || {}),
4868
+ openclawCron: {
4869
+ ...(currentAutomation.openclawCron || {}),
4870
+ enabled: enableOpenClawCron,
4871
+ mode: 'main',
4872
+ schedule: openclawCronSchedule || '*/30 * * * *',
4873
+ timezone: openclawCronTimezone || 'UTC',
4874
+ name: currentAutomation.openclawCron.name || 'OpenClaw Growth Engineer scheduler',
4875
+ delivery: {
4876
+ ...(currentAutomation.openclawCron.delivery || {}),
4877
+ enabled: currentAutomation.openclawCron.delivery?.enabled !== false,
4878
+ mode: currentAutomation.openclawCron.delivery?.mode || 'announce',
4879
+ channel: currentAutomation.openclawCron.delivery?.channel || 'last',
4880
+ to: currentAutomation.openclawCron.delivery?.to || '',
4881
+ },
4882
+ },
4883
+ hermesCron: {
4884
+ ...(currentAutomation.hermesCron || {}),
4885
+ enabled: enableHermesCron,
4886
+ schedule: hermesCronSchedule || '*/30 * * * *',
4887
+ name: currentAutomation.hermesCron.name || 'Hermes Growth Engineer scheduler',
4888
+ skill: currentAutomation.hermesCron.skill || 'growth-engineer',
4889
+ deliver: currentAutomation.hermesCron.deliver || 'local',
4890
+ },
4891
+ };
3325
4892
  return config;
3326
4893
  }
3327
4894
  async function askOutputsAndIntervalsConfig(rl, config) {
@@ -3330,6 +4897,9 @@ async function askOutputsAndIntervalsConfig(rl, config) {
3330
4897
  return await askGitHubArtifactDetails(rl, withOutput);
3331
4898
  }
3332
4899
  async function askInputSourceConfig(rl, config, configPath) {
4900
+ config = migrateRuntimeSourceCommands(config, configPath);
4901
+ await ensureDirForFile(configPath);
4902
+ await writeJsonFile(configPath, config);
3333
4903
  const healthByConnector = await withConnectorHealthLoading((onProgress) => getConnectorPickerHealth(configPath, onProgress));
3334
4904
  const selected = await askConnectorSelectionWithHealth(rl, healthByConnector, getInputChannelInitialSelection(config), {
3335
4905
  introTitle: 'Input channels',
@@ -3338,17 +4908,20 @@ async function askInputSourceConfig(rl, config, configPath) {
3338
4908
  helpText: 'Use Up/Down to move, Space to toggle channels, A to toggle all channels, Enter to continue.',
3339
4909
  mode: 'input',
3340
4910
  });
3341
- config.sources = buildSourceConfigFromInputChannels(selected, config.sources || {});
4911
+ config.sources = buildSourceConfigFromInputChannels(selected, config.sources || {}, configPath);
3342
4912
  return { config, selected, healthByConnector };
3343
4913
  }
3344
4914
  async function writeOpenClawJobManifest(configPath, config) {
3345
4915
  const manifestPath = path.resolve('.openclaw/jobs/openclaw-growth-engineer.json');
3346
4916
  const displayConfigPath = path.relative(process.cwd(), configPath) || configPath;
4917
+ const statePath = deriveStatePathFromConfigPath(displayConfigPath);
4918
+ const proofPath = deriveSchedulerProofPathFromStatePath(statePath);
3347
4919
  const intervalMinutes = Math.max(1, Number(config?.schedule?.intervalMinutes || DEFAULT_GROWTH_INTERVAL_MINUTES));
3348
4920
  const connectorHealthCheckIntervalMinutes = Math.max(1, Number(config?.schedule?.connectorHealthCheckIntervalMinutes || DEFAULT_CONNECTOR_HEALTH_INTERVAL_MINUTES));
3349
4921
  const actionMode = config?.actions?.mode || config?.deliveries?.github?.mode || 'issue';
3350
4922
  const growthRunCommand = getGrowthRunCommand(config, displayConfigPath);
3351
4923
  const connectorHealthCommand = getConnectorHealthCommand(config, displayConfigPath);
4924
+ const automation = getAutomationConfig(config);
3352
4925
  const manifest = {
3353
4926
  version: 1,
3354
4927
  generatedAt: new Date().toISOString(),
@@ -3359,12 +4932,27 @@ async function writeOpenClawJobManifest(configPath, config) {
3359
4932
  openClawCanEditOutputDelivery: true,
3360
4933
  openClawCanEditConnectors: true,
3361
4934
  openClawCanEditConnectorSecrets: false,
3362
- connectorChanges: 'OpenClaw may read and modify non-secret connector config such as enabled flags, source commands, project/app mappings, and source priorities. Use `node scripts/openclaw-growth-wizard.mjs --connectors` for API keys or other connector secrets; never write secret values into config files, manifests, issues, PRs, or chat output.',
4935
+ connectorChanges: 'OpenClaw may read and modify non-secret connector config such as enabled flags, source commands, project/app mappings, and source priorities. Use `npx -y @analyticscli/growth-engineer@preview wizard --connectors` for API keys or other connector secrets; never write secret values into config files, manifests, issues, PRs, or chat output.',
3363
4936
  secretAccessMode: config?.security?.connectorSecrets?.mode || 'local-user-file',
3364
4937
  secretPolicy: config?.security?.connectorSecrets?.mode === 'isolated-runner'
3365
4938
  ? 'OpenClaw must use the allowlisted sudo wrapper commands and must not read the persisted secret file.'
3366
4939
  : 'Secrets are persisted in a local chmod 600 file. This protects against other OS users, not against the same OS user.',
3367
4940
  },
4941
+ scheduler: {
4942
+ recommended: 'openclaw-cron',
4943
+ openclawCron: automation.openclawCron,
4944
+ hermesCron: automation.hermesCron,
4945
+ statePath,
4946
+ proofPath,
4947
+ verifyCommands: [
4948
+ 'openclaw cron list',
4949
+ 'openclaw tasks list',
4950
+ 'openclaw tasks audit',
4951
+ 'hermes cron list',
4952
+ `tail -n 20 ${proofPath}`,
4953
+ `jq '.connectorHealth, .cadences, .lastRunAt, .skippedReason' ${statePath}`,
4954
+ ],
4955
+ },
3368
4956
  jobs: [
3369
4957
  {
3370
4958
  key: 'connector-health',
@@ -3386,10 +4974,181 @@ async function writeOpenClawJobManifest(configPath, config) {
3386
4974
  await writeJsonFile(manifestPath, manifest);
3387
4975
  return manifestPath;
3388
4976
  }
4977
+ async function ensureOpenClawCronFromWizard(configPath, config) {
4978
+ const automation = getAutomationConfig(config).openclawCron;
4979
+ const displayConfigPath = path.relative(process.cwd(), configPath) || configPath;
4980
+ const statePath = deriveStatePathFromConfigPath(displayConfigPath);
4981
+ const proofPath = path.resolve(deriveSchedulerProofPathFromStatePath(statePath));
4982
+ if (automation.enabled === false) {
4983
+ return {
4984
+ ok: true,
4985
+ installed: false,
4986
+ status: 'disabled',
4987
+ detail: 'OpenClaw Gateway cron disabled by user choice.',
4988
+ statePath,
4989
+ proofPath,
4990
+ };
4991
+ }
4992
+ const addCommand = buildOpenClawCronAddCommand(displayConfigPath, config);
4993
+ if (!(await commandExists('openclaw'))) {
4994
+ return {
4995
+ ok: true,
4996
+ installed: false,
4997
+ status: 'openclaw_cli_missing',
4998
+ detail: 'openclaw CLI was not found on PATH. Run the shown command on the VPS shell where OpenClaw Gateway is installed.',
4999
+ command: addCommand,
5000
+ statePath,
5001
+ proofPath,
5002
+ };
5003
+ }
5004
+ const inspection = await inspectOpenClawCronInstall({
5005
+ configPath: displayConfigPath,
5006
+ config,
5007
+ runCommand: runCommandCapture,
5008
+ readFile: fs.readFile,
5009
+ });
5010
+ if (inspection.exists && inspection.verified) {
5011
+ return {
5012
+ ok: true,
5013
+ installed: true,
5014
+ status: 'already_configured_verified',
5015
+ detail: `OpenClaw cron job already exists and matches the Growth Engineer runner contract: ${automation.name}`,
5016
+ source: inspection.source,
5017
+ statePath,
5018
+ proofPath,
5019
+ };
5020
+ }
5021
+ const add = await runCommandCapture(addCommand);
5022
+ const existingDetail = inspection.exists
5023
+ ? `Existing OpenClaw cron job "${automation.name}" was not verifiably wired to the current runner contract (${inspection.reason} via ${inspection.source}). `
5024
+ : '';
5025
+ return {
5026
+ ok: add.ok,
5027
+ installed: add.ok,
5028
+ status: add.ok ? (inspection.exists ? 'reconfigured' : 'configured') : inspection.exists ? 'needs_repair' : 'failed',
5029
+ detail: add.ok
5030
+ ? `${existingDetail}Configured OpenClaw cron job: ${automation.name}`
5031
+ : `${existingDetail}${add.stderr || add.stdout || `exit ${add.code}`}`,
5032
+ command: addCommand,
5033
+ remediation: inspection.exists && !add.ok
5034
+ ? `Remove the stale OpenClaw cron job named "${automation.name}" with your installed OpenClaw CLI, then rerun: ${addCommand}`
5035
+ : undefined,
5036
+ statePath,
5037
+ proofPath,
5038
+ };
5039
+ }
5040
+ async function ensureHermesCronFromWizard(configPath, config) {
5041
+ const automation = getAutomationConfig(config).hermesCron;
5042
+ const displayConfigPath = path.relative(process.cwd(), configPath) || configPath;
5043
+ const statePath = deriveStatePathFromConfigPath(displayConfigPath);
5044
+ const proofPath = path.resolve(deriveSchedulerProofPathFromStatePath(statePath));
5045
+ const workdir = path.resolve(automation.workdir || process.cwd());
5046
+ if (automation.enabled === false) {
5047
+ return {
5048
+ ok: true,
5049
+ installed: false,
5050
+ status: 'disabled',
5051
+ detail: 'Hermes cron disabled by user choice.',
5052
+ statePath,
5053
+ proofPath,
5054
+ };
5055
+ }
5056
+ const createCommand = buildHermesCronCreateCommand(displayConfigPath, config, { workdir });
5057
+ if (!(await commandExists('hermes'))) {
5058
+ return {
5059
+ ok: true,
5060
+ installed: false,
5061
+ status: 'hermes_cli_missing',
5062
+ detail: 'hermes CLI was not found on PATH. Run the shown command on the host where Hermes Gateway is installed.',
5063
+ command: createCommand,
5064
+ statePath,
5065
+ proofPath,
5066
+ workdir,
5067
+ };
5068
+ }
5069
+ const inspection = await inspectHermesCronInstall({
5070
+ configPath: displayConfigPath,
5071
+ config,
5072
+ runCommand: runCommandCapture,
5073
+ readFile: fs.readFile,
5074
+ workdir,
5075
+ });
5076
+ if (inspection.exists && inspection.verified) {
5077
+ return {
5078
+ ok: true,
5079
+ installed: true,
5080
+ status: 'already_configured_verified',
5081
+ detail: `Hermes cron job already exists and matches the Growth Engineer runner contract: ${automation.name}`,
5082
+ source: inspection.source,
5083
+ statePath,
5084
+ proofPath,
5085
+ workdir,
5086
+ };
5087
+ }
5088
+ const create = await runCommandCapture(createCommand);
5089
+ const existingDetail = inspection.exists
5090
+ ? `Existing Hermes cron job "${automation.name}" was not verifiably wired to the current runner contract (${inspection.reason} via ${inspection.source}). `
5091
+ : '';
5092
+ return {
5093
+ ok: create.ok,
5094
+ installed: create.ok,
5095
+ status: create.ok ? (inspection.exists ? 'reconfigured' : 'configured') : inspection.exists ? 'needs_repair' : 'failed',
5096
+ detail: create.ok
5097
+ ? `${existingDetail}Configured Hermes cron job: ${automation.name}`
5098
+ : `${existingDetail}${create.stderr || create.stdout || `exit ${create.code}`}`,
5099
+ command: createCommand,
5100
+ remediation: inspection.exists && !create.ok
5101
+ ? `Remove the stale Hermes cron job named "${automation.name}" with your installed Hermes CLI, then rerun: ${createCommand}`
5102
+ : undefined,
5103
+ statePath,
5104
+ proofPath,
5105
+ workdir,
5106
+ };
5107
+ }
5108
+ function printOpenClawCronResult(result) {
5109
+ process.stdout.write(`OpenClaw cron: ${result.status} - ${result.detail}\n`);
5110
+ if (result.command && result.status === 'openclaw_cli_missing') {
5111
+ process.stdout.write('\nRun this on the VPS where OpenClaw Gateway is installed:\n');
5112
+ process.stdout.write(`${result.command}\n`);
5113
+ }
5114
+ if (result.remediation) {
5115
+ process.stdout.write('\nOpenClaw cron repair:\n');
5116
+ process.stdout.write(`${result.remediation}\n`);
5117
+ }
5118
+ process.stdout.write('\nVPS verification commands:\n');
5119
+ process.stdout.write(' openclaw cron list\n');
5120
+ process.stdout.write(' openclaw tasks list\n');
5121
+ process.stdout.write(' openclaw tasks audit\n');
5122
+ process.stdout.write(` tail -n 20 ${result.proofPath || path.resolve(DEFAULT_SCHEDULER_PROOF_PATH)}\n`);
5123
+ process.stdout.write(` jq '.connectorHealth, .cadences, .lastRunAt, .skippedReason' ${result.statePath || 'data/openclaw-growth-engineer/state.json'}\n`);
5124
+ }
5125
+ function printHermesCronResult(result) {
5126
+ process.stdout.write(`Hermes cron: ${result.status} - ${result.detail}\n`);
5127
+ if (result.command && result.status === 'hermes_cli_missing') {
5128
+ process.stdout.write('\nRun this on the host where Hermes Gateway is installed:\n');
5129
+ process.stdout.write(`${result.command}\n`);
5130
+ }
5131
+ if (result.remediation) {
5132
+ process.stdout.write('\nHermes cron repair:\n');
5133
+ process.stdout.write(`${result.remediation}\n`);
5134
+ }
5135
+ process.stdout.write('\nHermes verification commands:\n');
5136
+ process.stdout.write(' hermes cron list\n');
5137
+ process.stdout.write(' hermes gateway status\n');
5138
+ process.stdout.write(` tail -n 20 ${result.proofPath || path.resolve(DEFAULT_SCHEDULER_PROOF_PATH)}\n`);
5139
+ process.stdout.write(` jq '.connectorHealth, .cadences, .lastRunAt, .skippedReason' ${result.statePath || 'data/openclaw-growth-engineer/state.json'}\n`);
5140
+ }
3389
5141
  async function main() {
3390
5142
  await loadOpenClawGrowthSecrets();
3391
5143
  const args = parseArgs(process.argv.slice(2));
3392
5144
  await maybeSelfUpdateFromClawHub(args);
5145
+ if (args.sandboxSmoke) {
5146
+ const configPath = path.resolve(args.out);
5147
+ const config = await loadEditableConfig(configPath);
5148
+ await writeJsonFile(configPath, config);
5149
+ process.stdout.write(`${JSON.stringify({ ok: true, configPath, sources: config.sources || {} })}\n`);
5150
+ return;
5151
+ }
3393
5152
  if (args.connectorWizard) {
3394
5153
  await runConnectorSetupWizard(args);
3395
5154
  return;
@@ -3412,8 +5171,12 @@ async function main() {
3412
5171
  const secretAccess = await askSecretAccessModel(rl, configPath, config);
3413
5172
  await writeJsonFile(configPath, config);
3414
5173
  const manifestPath = await writeOpenClawJobManifest(configPath, config);
5174
+ const cronResult = await ensureOpenClawCronFromWizard(configPath, config);
5175
+ const hermesCronResult = await ensureHermesCronFromWizard(configPath, config);
3415
5176
  process.stdout.write(`\nSaved schedule config: ${configPath}\n`);
3416
5177
  process.stdout.write(`Saved OpenClaw job manifest: ${manifestPath}\n`);
5178
+ printOpenClawCronResult(cronResult);
5179
+ printHermesCronResult(hermesCronResult);
3417
5180
  printSecretRunnerKitInstructions(secretAccess.kit);
3418
5181
  process.stdout.write('OpenClaw can run and update growth jobs plus non-secret connector config from the manifest; connector API keys stay behind the connector wizard.\n');
3419
5182
  return;
@@ -3423,8 +5186,12 @@ async function main() {
3423
5186
  const secretAccess = await askSecretAccessModel(rl, configPath, config);
3424
5187
  await writeJsonFile(configPath, config);
3425
5188
  const manifestPath = await writeOpenClawJobManifest(configPath, config);
5189
+ const cronResult = await ensureOpenClawCronFromWizard(configPath, config);
5190
+ const hermesCronResult = await ensureHermesCronFromWizard(configPath, config);
3426
5191
  process.stdout.write(`\nSaved output and interval config: ${configPath}\n`);
3427
5192
  process.stdout.write(`Saved OpenClaw job manifest: ${manifestPath}\n`);
5193
+ printOpenClawCronResult(cronResult);
5194
+ printHermesCronResult(hermesCronResult);
3428
5195
  printSecretRunnerKitInstructions(secretAccess.kit);
3429
5196
  process.stdout.write('Daily checks prioritize Sentry and production anomalies; larger cadences analyze all configured projects and connectors.\n');
3430
5197
  return;
@@ -3456,13 +5223,17 @@ async function main() {
3456
5223
  await ensureDirForFile(configPath);
3457
5224
  await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
3458
5225
  const manifestPath = await writeOpenClawJobManifest(configPath, config);
5226
+ const cronResult = await ensureOpenClawCronFromWizard(configPath, config);
5227
+ const hermesCronResult = await ensureHermesCronFromWizard(configPath, config);
3459
5228
  process.stdout.write(`\nSaved config: ${configPath}\n`);
3460
5229
  process.stdout.write(`Saved OpenClaw job manifest: ${manifestPath}\n`);
5230
+ printOpenClawCronResult(cronResult);
5231
+ printHermesCronResult(hermesCronResult);
3461
5232
  printSecretRunnerKitInstructions(secretAccess.kit);
3462
5233
  process.stdout.write('\nNext steps:\n');
3463
5234
  process.stdout.write(`1) Set secrets in OpenClaw secret store (env var names in config.secrets)\n`);
3464
- process.stdout.write(`2) Run once: node scripts/openclaw-growth-runner.mjs --config ${configPath}\n`);
3465
- process.stdout.write(`3) Run interval loop: node scripts/openclaw-growth-runner.mjs --config ${configPath} --loop\n`);
5235
+ process.stdout.write(`2) Run once: ${growthEngineerPackageCommand(`run --config ${quote(configPath)}`)}\n`);
5236
+ process.stdout.write('3) Prefer OpenClaw Gateway cron for recurring VPS runs; use the interval loop only as a manual fallback.\n');
3466
5237
  }
3467
5238
  finally {
3468
5239
  rl.close();