@analyticscli/growth-engineer 0.1.0-preview.1 → 0.1.0-preview.11

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.
@@ -1,17 +1,30 @@
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, getDefaultSourceHint, getDefaultSourcePath, } from './openclaw-growth-shared.mjs';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { buildExtraSourceConfig, getDefaultSourceCommand, } 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;
15
+ const DEFAULT_GROWTH_INTERVAL_MINUTES = 1440;
16
+ const DEFAULT_CONNECTOR_HEALTH_INTERVAL_MINUTES = 360;
17
+ const GROWTH_ENGINEER_PACKAGE_SPEC = process.env.OPENCLAW_GROWTH_ENGINEER_PACKAGE || '@analyticscli/growth-engineer@preview';
18
+ const RUNTIME_DIR = path.dirname(fileURLToPath(import.meta.url));
14
19
  const CONNECTOR_KEYS = ['analytics', 'github', 'revenuecat', 'sentry', 'asc'];
20
+ class WizardAbortError extends Error {
21
+ exitCode;
22
+ constructor(message, exitCode = 130) {
23
+ super(message);
24
+ this.name = 'WizardAbortError';
25
+ this.exitCode = exitCode;
26
+ }
27
+ }
15
28
  const CONNECTOR_DEFINITIONS = [
16
29
  {
17
30
  key: 'analytics',
@@ -47,33 +60,33 @@ const CONNECTOR_DEFINITIONS = [
47
60
  const DEFAULT_CADENCE_PLAN = [
48
61
  {
49
62
  key: 'daily',
50
- title: 'Daily production guardrail',
63
+ title: 'Daily Sentry and production guardrail',
51
64
  intervalDays: 1,
52
65
  criticalOnly: true,
53
- focusAreas: ['crash', 'conversion', 'paywall'],
54
- sourcePriorities: ['sentry', 'glitchtip', 'analytics', 'asc_cli', 'revenuecat'],
55
- objective: 'Only investigate critical production blockers and business anomalies: Sentry/GlitchTip production errors, crashes, very low users, conversion, purchases, or other urgent drops.',
56
- instructions: 'Do exact root-cause analysis with connected production data, memory/state, release context, and recent code changes. Produce the fix or next debugging step; avoid generic growth ideas.',
66
+ focusAreas: ['sentry_errors', 'crash', 'onboarding', 'conversion', 'paywall', 'purchase'],
67
+ sourcePriorities: ['sentry', 'glitchtip', 'analytics', 'revenuecat', 'asc_cli', 'feedback', 'github'],
68
+ 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.',
69
+ 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.',
57
70
  },
58
71
  {
59
72
  key: 'weekly',
60
- title: 'Weekly conversion, traffic, and RevenueCat review',
73
+ title: 'Weekly executive product and growth summary',
61
74
  intervalDays: 7,
62
75
  criticalOnly: false,
63
- focusAreas: ['conversion', 'paywall', 'onboarding', 'marketing', 'retention'],
64
- sourcePriorities: ['analytics', 'revenuecat', 'asc_cli', 'feedback', 'sentry'],
65
- objective: 'Analyze total conversion, traffic quality, activation, retention, RevenueCat trials/subscriptions/revenue/churn, source mix, reviews, releases, and stability.',
66
- instructions: 'Pick one to three high-confidence growth bets with evidence, expected KPI movement, likely code/store surfaces, and verification plan.',
76
+ focusAreas: ['conversion', 'paywall', 'onboarding', 'marketing', 'retention', 'stability'],
77
+ sourcePriorities: ['analytics', 'revenuecat', 'asc_cli', 'feedback', 'sentry', 'github'],
78
+ objective: 'Create an executive summary across all configured projects, connectors, recent releases, code changes, revenue, activation, retention, reviews, and production stability.',
79
+ 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.',
67
80
  },
68
81
  {
69
82
  key: 'monthly',
70
- title: 'Monthly business and product review',
83
+ title: 'Monthly deep product, business, and code review',
71
84
  intervalDays: 30,
72
85
  criticalOnly: false,
73
- focusAreas: ['conversion', 'paywall', 'retention', 'marketing', 'onboarding'],
74
- sourcePriorities: ['analytics', 'revenuecat', 'asc_cli', 'feedback', 'sentry'],
75
- objective: 'Compare MRR, trial conversion, churn, acquisition quality, store conversion, retention, review themes, feature usage, and crash totals month-over-month.',
76
- instructions: 'Decide what should be built, changed, or deleted next and explain why it should move revenue, activation, retention, or acquisition quality.',
86
+ focusAreas: ['conversion', 'paywall', 'retention', 'marketing', 'onboarding', 'codebase'],
87
+ sourcePriorities: ['analytics', 'revenuecat', 'asc_cli', 'feedback', 'sentry', 'github'],
88
+ 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.',
89
+ 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.',
77
90
  },
78
91
  {
79
92
  key: 'quarterly',
@@ -81,8 +94,8 @@ const DEFAULT_CADENCE_PLAN = [
81
94
  intervalDays: 91,
82
95
  criticalOnly: false,
83
96
  focusAreas: ['marketing', 'paywall', 'retention', 'conversion', 'onboarding'],
84
- sourcePriorities: ['analytics', 'revenuecat', 'asc_cli', 'feedback'],
85
- objective: 'Revisit positioning, pricing/packaging, onboarding architecture, roadmap assumptions, tracking quality, and major funnel bets.',
97
+ sourcePriorities: ['analytics', 'revenuecat', 'asc_cli', 'feedback', 'github', 'sentry'],
98
+ objective: 'Revisit positioning, pricing/packaging, onboarding architecture, roadmap assumptions, tracking quality, codebase constraints, and major funnel bets across every configured project.',
86
99
  instructions: 'Find structural constraints and durable opportunities. Tie recommendations to cohort behavior, monetization, reviews, channel quality, and shipped changes.',
87
100
  },
88
101
  {
@@ -92,7 +105,7 @@ const DEFAULT_CADENCE_PLAN = [
92
105
  criticalOnly: false,
93
106
  focusAreas: ['retention', 'conversion', 'paywall', 'marketing', 'general'],
94
107
  sourcePriorities: ['analytics', 'revenuecat', 'asc_cli', 'feedback', 'sentry'],
95
- objective: 'Audit connector coverage, SDK instrumentation, event taxonomy, data reliability, memory, growth loops, and whether strategy still matches the best users.',
108
+ 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.',
96
109
  instructions: 'Prioritize measurement fixes and system changes that make future analysis more trustworthy. Identify stale events, missing attribution, weak identity, and misleading dashboards.',
97
110
  },
98
111
  {
@@ -102,7 +115,7 @@ const DEFAULT_CADENCE_PLAN = [
102
115
  criticalOnly: false,
103
116
  focusAreas: ['marketing', 'retention', 'paywall', 'conversion', 'general'],
104
117
  sourcePriorities: ['analytics', 'revenuecat', 'asc_cli', 'feedback', 'sentry'],
105
- objective: 'Reset strategy from evidence: market/channel fit, monetization model, retention ceiling, product scope, and whether to double down, reposition, rebuild, or sunset major surfaces/features.',
118
+ 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.',
106
119
  instructions: 'Use the full year of memory, releases, revenue, acquisition, reviews, code changes, and cohort behavior. Produce strategic experiments and stop-doing decisions.',
107
120
  },
108
121
  ];
@@ -210,6 +223,91 @@ function quote(value) {
210
223
  }
211
224
  return `'${String(value).replace(/'/g, `'\\''`)}'`;
212
225
  }
226
+ function resolveRuntimeScriptPath(scriptName) {
227
+ const candidates = [
228
+ path.join(RUNTIME_DIR, scriptName),
229
+ path.resolve('scripts', scriptName),
230
+ path.resolve('skills/openclaw-growth-engineer/scripts', scriptName),
231
+ ];
232
+ return candidates.find((candidate) => existsSync(candidate)) || path.join(RUNTIME_DIR, scriptName);
233
+ }
234
+ function nodeRuntimeScriptCommand(scriptName) {
235
+ return `node ${quote(resolveRuntimeScriptPath(scriptName))}`;
236
+ }
237
+ function growthEngineerPackageCommand(args) {
238
+ return `npx -y ${quote(GROWTH_ENGINEER_PACKAGE_SPEC)} ${args}`;
239
+ }
240
+ function getWizardDefaultSourceCommand(sourceName) {
241
+ const normalized = String(sourceName || '').trim().toLowerCase();
242
+ if (normalized === 'analytics' || normalized === 'analyticscli') {
243
+ return nodeRuntimeScriptCommand('export-analytics-summary.mjs');
244
+ }
245
+ if (normalized === 'revenuecat' || normalized === 'revenue-cat') {
246
+ return nodeRuntimeScriptCommand('export-revenuecat-summary.mjs');
247
+ }
248
+ if (normalized === 'sentry' || normalized === 'glitchtip') {
249
+ return nodeRuntimeScriptCommand('export-sentry-summary.mjs');
250
+ }
251
+ if (normalized === 'feedback') {
252
+ return getDefaultSourceCommand('feedback');
253
+ }
254
+ if (['asc', 'asc-cli', 'app-store-connect', 'app_store_connect'].includes(normalized)) {
255
+ return nodeRuntimeScriptCommand('export-asc-summary.mjs');
256
+ }
257
+ return getDefaultSourceCommand(sourceName);
258
+ }
259
+ function replaceLegacyRuntimeScriptCommand(command) {
260
+ const trimmed = String(command || '').trim();
261
+ if (!trimmed)
262
+ return trimmed;
263
+ return trimmed.replace(/^node\s+scripts\/(export-analytics-summary\.mjs|export-revenuecat-summary\.mjs|export-sentry-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));
264
+ }
265
+ function normalizeWizardSourceCommand(sourceName, source) {
266
+ const current = replaceLegacyRuntimeScriptCommand(source?.command || '');
267
+ return current || getWizardDefaultSourceCommand(sourceName);
268
+ }
269
+ function migrateRuntimeSourceCommands(config) {
270
+ if (!config || typeof config !== 'object')
271
+ return config;
272
+ const sources = config.sources && typeof config.sources === 'object' ? config.sources : {};
273
+ const nextSources = { ...sources };
274
+ for (const sourceName of ['analytics', 'revenuecat', 'sentry']) {
275
+ if (nextSources[sourceName]?.mode === 'command') {
276
+ nextSources[sourceName] = {
277
+ ...nextSources[sourceName],
278
+ command: normalizeWizardSourceCommand(sourceName, nextSources[sourceName]),
279
+ };
280
+ }
281
+ }
282
+ if (Array.isArray(nextSources.extra)) {
283
+ nextSources.extra = nextSources.extra.map((source) => {
284
+ if (!source || source.mode !== 'command')
285
+ return source;
286
+ const service = String(source.service || source.key || '').toLowerCase();
287
+ const sourceName = ['asc', 'asc-cli', 'app-store-connect', 'app_store_connect'].includes(service)
288
+ ? 'asc'
289
+ : service;
290
+ return {
291
+ ...source,
292
+ command: normalizeWizardSourceCommand(sourceName, source),
293
+ };
294
+ });
295
+ }
296
+ return {
297
+ ...config,
298
+ sources: nextSources,
299
+ };
300
+ }
301
+ async function migrateRuntimeSourceCommandsFile(configPath) {
302
+ const existing = await readJsonIfPresent(configPath).catch(() => null);
303
+ if (!existing || typeof existing !== 'object')
304
+ return null;
305
+ const migrated = migrateRuntimeSourceCommands(existing);
306
+ if (JSON.stringify(existing.sources || {}) !== JSON.stringify(migrated.sources || {})) {
307
+ await writeJsonFile(configPath, migrated);
308
+ }
309
+ return migrated;
310
+ }
213
311
  function normalizeConnectorKey(value) {
214
312
  const normalized = String(value || '').trim().toLowerCase().replace(/[_\s]+/g, '-');
215
313
  if (!normalized)
@@ -268,23 +366,28 @@ function withMissingRequiredAnalyticsConnector(selected) {
268
366
  return orderConnectors(selected);
269
367
  return orderConnectors(['analytics', ...selected]);
270
368
  }
271
- async function askConnectorSelection(rl) {
272
- return askConnectorSelectionWithHealth(rl, {}, []);
273
- }
274
- async function askConnectorSelectionWithHealth(rl, healthByConnector = {}, initialSelected = []) {
369
+ async function askConnectorSelectionWithHealth(rl, healthByConnector = {}, initialSelected = [], copy = {}) {
275
370
  if (!process.stdin.isTTY || !process.stdout.isTTY || !process.stdin.setRawMode) {
276
- return await askConnectorSelectionByText(rl, healthByConnector);
371
+ return await askConnectorSelectionByText(rl, healthByConnector, copy);
277
372
  }
278
373
  rl.pause();
374
+ let completed = false;
279
375
  try {
280
- return await askConnectorSelectionByKeys(healthByConnector, initialSelected);
376
+ const selected = await askConnectorSelectionByKeys(healthByConnector, initialSelected, copy);
377
+ completed = true;
378
+ return selected;
281
379
  }
282
380
  finally {
283
- rl.resume();
381
+ if (completed) {
382
+ rl.resume();
383
+ }
384
+ else {
385
+ process.stdin.pause();
386
+ }
284
387
  }
285
388
  }
286
- async function askConnectorSelectionByText(rl, healthByConnector = {}) {
287
- printConnectorIntro();
389
+ async function askConnectorSelectionByText(rl, healthByConnector = {}, copy = {}) {
390
+ printConnectorIntro(copy);
288
391
  for (const group of connectorPickerGroups(healthByConnector)) {
289
392
  process.stdout.write(`${ANSI.bold}${group.title}${ANSI.reset}\n`);
290
393
  for (const connector of group.connectors) {
@@ -322,26 +425,281 @@ function orderConnectors(keys) {
322
425
  const selected = new Set(keys);
323
426
  return CONNECTOR_KEYS.filter((key) => selected.has(key));
324
427
  }
325
- function printConnectorIntro() {
326
- process.stdout.write(`\n${ANSI.bold}OpenClaw connector setup${ANSI.reset}\n`);
327
- process.stdout.write(`${ANSI.dim}Secrets stay local on this host. Do not paste them into any chat or social channel.${ANSI.reset}\n\n`);
428
+ function printConnectorIntro(copy = {}) {
429
+ process.stdout.write(`\n${ANSI.bold}${copy.introTitle || 'OpenClaw connector setup'}${ANSI.reset}\n`);
430
+ const detail = copy.introDetail === undefined
431
+ ? 'You can configure connector secrets here. API keys stay in this host\'s local secrets file, not in chat or config JSON.'
432
+ : copy.introDetail;
433
+ if (detail) {
434
+ process.stdout.write(`${ANSI.dim}${detail}${ANSI.reset}\n`);
435
+ }
436
+ process.stdout.write('\n');
328
437
  }
329
- async function withTerminalLoading(message, task) {
330
- const frames = ['-', '\\', '|', '/'];
331
- let index = 0;
332
- process.stdout.write(`${message} ${frames[index]}`);
333
- const timer = setInterval(() => {
334
- index = (index + 1) % frames.length;
335
- process.stdout.write(`\r${message} ${frames[index]}`);
336
- }, 120);
438
+ async function askMenuChoice(rl, { title, subtitle = 'Use Up/Down to move, Enter to continue.', options, defaultValue, renderHeader, }) {
439
+ if (!process.stdin.isTTY || !process.stdout.isTTY || !process.stdin.setRawMode) {
440
+ process.stdout.write(`\n${title}\n`);
441
+ options.forEach((option, index) => {
442
+ process.stdout.write(` ${index + 1}) ${option.label}: ${option.detail}\n`);
443
+ });
444
+ const defaultIndex = Math.max(0, options.findIndex((option) => option.value === defaultValue));
445
+ const answer = await ask(rl, `Setup area (1-${options.length})`, String(defaultIndex + 1));
446
+ const selected = options[Number(answer.trim()) - 1] || options[defaultIndex];
447
+ return selected.value;
448
+ }
449
+ rl.pause();
450
+ let completed = false;
337
451
  try {
338
- return await task;
452
+ const selected = await askMenuChoiceByKeys({ title, subtitle, options, defaultValue, renderHeader });
453
+ completed = true;
454
+ return selected;
339
455
  }
340
456
  finally {
341
- clearInterval(timer);
342
- process.stdout.write(`\r${message} done\n`);
457
+ if (completed) {
458
+ rl.resume();
459
+ }
460
+ else {
461
+ process.stdin.pause();
462
+ }
463
+ }
464
+ }
465
+ async function askMultiChoice(rl, { title, subtitle = 'Use Up/Down to move, Space to toggle, Enter to continue.', options, defaultValues, requiredValues = [], minSelections = 1, renderHeader, }) {
466
+ const required = new Set(requiredValues);
467
+ const normalizeSelection = (values) => {
468
+ const selected = new Set(values);
469
+ requiredValues.forEach((value) => selected.add(value));
470
+ return options.map((option) => option.value).filter((value) => selected.has(value));
471
+ };
472
+ if (!process.stdin.isTTY || !process.stdout.isTTY || !process.stdin.setRawMode) {
473
+ process.stdout.write(`\n${title}\n`);
474
+ options.forEach((option, index) => {
475
+ const checked = defaultValues.includes(option.value) || required.has(option.value) ? 'x' : ' ';
476
+ const requiredLabel = required.has(option.value) ? ' required' : '';
477
+ process.stdout.write(` ${index + 1}) [${checked}] ${option.label}${requiredLabel}: ${option.detail}\n`);
478
+ });
479
+ 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(','));
480
+ const selected = answer
481
+ .split(',')
482
+ .map((value) => Number.parseInt(value.trim(), 10) - 1)
483
+ .filter((index) => options[index])
484
+ .map((index) => options[index].value);
485
+ const normalized = normalizeSelection(selected);
486
+ return normalized.length >= minSelections ? normalized : normalizeSelection(defaultValues);
487
+ }
488
+ rl.pause();
489
+ let completed = false;
490
+ try {
491
+ const selected = await askMultiChoiceByKeys({
492
+ title,
493
+ subtitle,
494
+ options,
495
+ defaultValues: normalizeSelection(defaultValues),
496
+ requiredValues,
497
+ minSelections,
498
+ renderHeader,
499
+ });
500
+ completed = true;
501
+ return selected;
502
+ }
503
+ finally {
504
+ if (completed) {
505
+ rl.resume();
506
+ }
507
+ else {
508
+ process.stdin.pause();
509
+ }
343
510
  }
344
511
  }
512
+ async function askMultiChoiceByKeys({ title, subtitle, options, defaultValues, requiredValues, minSelections, renderHeader, }) {
513
+ emitKeypressEvents(process.stdin);
514
+ const wasRaw = process.stdin.isRaw;
515
+ const wasPaused = process.stdin.isPaused();
516
+ process.stdin.setRawMode(true);
517
+ process.stdin.resume();
518
+ const required = new Set(requiredValues);
519
+ const selected = new Set(defaultValues);
520
+ requiredValues.forEach((value) => selected.add(value));
521
+ let cursorIndex = 0;
522
+ let warning = '';
523
+ return await new Promise((resolve, reject) => {
524
+ const cleanup = () => {
525
+ process.stdin.off('keypress', onKeypress);
526
+ process.stdin.setRawMode(Boolean(wasRaw));
527
+ if (wasPaused) {
528
+ process.stdin.pause();
529
+ }
530
+ process.stdout.write(ANSI.showCursor);
531
+ };
532
+ const selectedValues = () => options.map((option) => option.value).filter((value) => selected.has(value));
533
+ const render = () => {
534
+ process.stdout.write('\x1b[2J\x1b[H');
535
+ renderHeader?.();
536
+ process.stdout.write(`\n${ANSI.bold}${title}${ANSI.reset}\n`);
537
+ process.stdout.write(`${ANSI.dim}${subtitle}${ANSI.reset}\n\n`);
538
+ if (warning) {
539
+ process.stdout.write(`${ANSI.cyan}${warning}${ANSI.reset}\n\n`);
540
+ }
541
+ for (let index = 0; index < options.length; index += 1) {
542
+ const option = options[index];
543
+ const pointer = index === cursorIndex ? `${ANSI.cyan}>${ANSI.reset}` : ' ';
544
+ const checkbox = selected.has(option.value) ? '[x]' : '[ ]';
545
+ const requiredLabel = required.has(option.value) ? ` ${ANSI.dim}(required)${ANSI.reset}` : '';
546
+ process.stdout.write(`${pointer} ${checkbox} ${index + 1}) ${ANSI.bold}${option.label}${ANSI.reset}${requiredLabel}\n`);
547
+ writeWrapped(option.detail, ' ', ANSI.dim);
548
+ }
549
+ 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`);
550
+ };
551
+ const cancel = () => {
552
+ cleanup();
553
+ process.stdout.write('\n');
554
+ reject(new WizardAbortError('Setup cancelled.'));
555
+ };
556
+ const finish = () => {
557
+ const values = selectedValues();
558
+ if (values.length < minSelections) {
559
+ warning = `Select at least ${minSelections} item${minSelections === 1 ? '' : 's'} to continue.`;
560
+ render();
561
+ return;
562
+ }
563
+ cleanup();
564
+ process.stdout.write('\x1b[2J\x1b[H');
565
+ resolve(values);
566
+ };
567
+ const toggleIndex = (index) => {
568
+ const option = options[index];
569
+ if (!option)
570
+ return;
571
+ warning = '';
572
+ if (required.has(option.value)) {
573
+ selected.add(option.value);
574
+ warning = `${option.label} is required.`;
575
+ return;
576
+ }
577
+ if (selected.has(option.value))
578
+ selected.delete(option.value);
579
+ else
580
+ selected.add(option.value);
581
+ requiredValues.forEach((value) => selected.add(value));
582
+ };
583
+ const onKeypress = (_text, key) => {
584
+ if (key?.ctrl && key?.name === 'c') {
585
+ cancel();
586
+ return;
587
+ }
588
+ if (key?.name === 'escape' || key?.name === 'q') {
589
+ cancel();
590
+ return;
591
+ }
592
+ if (key?.name === 'up' || key?.name === 'k') {
593
+ cursorIndex = (cursorIndex - 1 + options.length) % options.length;
594
+ warning = '';
595
+ }
596
+ else if (key?.name === 'down' || key?.name === 'j') {
597
+ cursorIndex = (cursorIndex + 1) % options.length;
598
+ warning = '';
599
+ }
600
+ else if (key?.name === 'space') {
601
+ toggleIndex(cursorIndex);
602
+ }
603
+ else if (String(_text || '').toLowerCase() === 'a') {
604
+ const optional = options.filter((option) => !required.has(option.value));
605
+ const allSelected = optional.every((option) => selected.has(option.value));
606
+ optional.forEach((option) => {
607
+ if (allSelected)
608
+ selected.delete(option.value);
609
+ else
610
+ selected.add(option.value);
611
+ });
612
+ requiredValues.forEach((value) => selected.add(value));
613
+ warning = '';
614
+ }
615
+ else if (key?.name === 'return' || key?.name === 'enter') {
616
+ finish();
617
+ return;
618
+ }
619
+ else if (/^[1-9]$/.test(String(_text || ''))) {
620
+ toggleIndex(Number(_text) - 1);
621
+ }
622
+ render();
623
+ };
624
+ process.stdin.on('keypress', onKeypress);
625
+ process.stdout.write(ANSI.hideCursor);
626
+ render();
627
+ });
628
+ }
629
+ async function askMenuChoiceByKeys({ title, subtitle, options, defaultValue, renderHeader, }) {
630
+ emitKeypressEvents(process.stdin);
631
+ const wasRaw = process.stdin.isRaw;
632
+ const wasPaused = process.stdin.isPaused();
633
+ process.stdin.setRawMode(true);
634
+ process.stdin.resume();
635
+ let cursorIndex = Math.max(0, options.findIndex((option) => option.value === defaultValue));
636
+ return await new Promise((resolve, reject) => {
637
+ const cleanup = () => {
638
+ process.stdin.off('keypress', onKeypress);
639
+ process.stdin.setRawMode(Boolean(wasRaw));
640
+ if (wasPaused) {
641
+ process.stdin.pause();
642
+ }
643
+ process.stdout.write(ANSI.showCursor);
644
+ };
645
+ const render = () => {
646
+ process.stdout.write('\x1b[2J\x1b[H');
647
+ renderHeader?.();
648
+ process.stdout.write(`\n${ANSI.bold}${title}${ANSI.reset}\n`);
649
+ process.stdout.write(`${ANSI.dim}${subtitle}${ANSI.reset}\n\n`);
650
+ for (let index = 0; index < options.length; index += 1) {
651
+ const option = options[index];
652
+ const pointer = index === cursorIndex ? `${ANSI.cyan}>${ANSI.reset}` : ' ';
653
+ const number = `${index + 1})`;
654
+ process.stdout.write(`${pointer} ${number} ${ANSI.bold}${option.label}${ANSI.reset}\n`);
655
+ writeWrapped(option.detail, ' ', ANSI.dim);
656
+ }
657
+ process.stdout.write(`\n${ANSI.dim}Esc/Q cancels. Number keys 1-${options.length} select directly.${ANSI.reset}\n`);
658
+ };
659
+ const cancel = () => {
660
+ cleanup();
661
+ process.stdout.write('\n');
662
+ reject(new WizardAbortError('Setup cancelled.'));
663
+ };
664
+ const finish = () => {
665
+ cleanup();
666
+ process.stdout.write('\x1b[2J\x1b[H');
667
+ resolve(options[cursorIndex]?.value || defaultValue);
668
+ };
669
+ const onKeypress = (_text, key) => {
670
+ if (key?.ctrl && key?.name === 'c') {
671
+ cancel();
672
+ return;
673
+ }
674
+ if (key?.name === 'escape' || key?.name === 'q') {
675
+ cancel();
676
+ return;
677
+ }
678
+ if (key?.name === 'up' || key?.name === 'k') {
679
+ cursorIndex = (cursorIndex - 1 + options.length) % options.length;
680
+ }
681
+ else if (key?.name === 'down' || key?.name === 'j') {
682
+ cursorIndex = (cursorIndex + 1) % options.length;
683
+ }
684
+ else if (key?.name === 'return' || key?.name === 'enter') {
685
+ finish();
686
+ return;
687
+ }
688
+ else if (/^[1-9]$/.test(String(_text || ''))) {
689
+ const selectedIndex = Number(_text) - 1;
690
+ if (options[selectedIndex]) {
691
+ cursorIndex = selectedIndex;
692
+ finish();
693
+ return;
694
+ }
695
+ }
696
+ render();
697
+ };
698
+ process.stdin.on('keypress', onKeypress);
699
+ process.stdout.write(ANSI.hideCursor);
700
+ render();
701
+ });
702
+ }
345
703
  function normalizeConnectorProgressKey(key) {
346
704
  const normalized = String(key || '').trim().toLowerCase();
347
705
  if (normalized === 'analytics' || normalized === 'analyticscli')
@@ -439,9 +797,6 @@ function connectorStatusLabel(key, healthByConnector = {}) {
439
797
  return 'not configured';
440
798
  return `configured, ${connectorHealthLabel(health.status)}`;
441
799
  }
442
- function formatConnectorHealthLine(key, healthByConnector = {}) {
443
- return `${ANSI.dim}${formatConnectorHealthText(key, healthByConnector)}${ANSI.reset}`;
444
- }
445
800
  function formatConnectorHealthText(key, healthByConnector = {}) {
446
801
  const health = getConnectorHealth(key, healthByConnector);
447
802
  const label = connectorStatusLabel(key, healthByConnector);
@@ -517,7 +872,7 @@ async function getConnectorPickerHealth(configPath, onProgress = () => { }) {
517
872
  },
518
873
  ]));
519
874
  }
520
- const result = await runCommandCaptureWithProgress(`node scripts/openclaw-growth-status.mjs --config ${quote(configPath)} --json --progress-json`, onProgress);
875
+ const result = await runCommandCaptureWithProgress(`${nodeRuntimeScriptCommand('openclaw-growth-status.mjs')} --config ${quote(configPath)} --json --progress-json`, onProgress);
521
876
  const payload = parseJsonFromStdout(result.stdout);
522
877
  const connectors = payload?.connectors && typeof payload.connectors === 'object' ? payload.connectors : {};
523
878
  const healthByConnector = {
@@ -529,11 +884,11 @@ async function getConnectorPickerHealth(configPath, onProgress = () => { }) {
529
884
  };
530
885
  return Object.fromEntries(CONNECTOR_KEYS.map((key) => [key, getConnectorHealth(key, healthByConnector)]));
531
886
  }
532
- function renderConnectorPicker(cursorIndex, selected, required, healthByConnector = {}, warning = '') {
887
+ function renderConnectorPicker(cursorIndex, selected, required, healthByConnector = {}, warning = '', copy = {}) {
533
888
  process.stdout.write('\x1b[2J\x1b[H');
534
- printConnectorIntro();
535
- process.stdout.write(`${ANSI.bold}Select connectors to set up or overwrite now${ANSI.reset}\n`);
536
- writeWrapped('Use Up/Down to move, Space to toggle optional connectors, A to toggle all optional connectors, Enter to continue.', '', ANSI.dim);
889
+ printConnectorIntro(copy);
890
+ process.stdout.write(`${ANSI.bold}${copy.actionTitle || 'Select connectors to set up or overwrite now'}${ANSI.reset}\n`);
891
+ writeWrapped(copy.helpText || 'Use Up/Down to move, Space to toggle optional connectors, A to toggle all optional connectors, Enter to continue.', '', ANSI.dim);
537
892
  process.stdout.write('\n');
538
893
  let index = 0;
539
894
  for (const group of connectorPickerGroups(healthByConnector)) {
@@ -559,16 +914,18 @@ function renderConnectorPicker(cursorIndex, selected, required, healthByConnecto
559
914
  }
560
915
  process.stdout.write(`${ANSI.dim}Esc/Q cancels. Number keys 1-${CONNECTOR_DEFINITIONS.length} also toggle connectors.${ANSI.reset}\n`);
561
916
  }
562
- async function askConnectorSelectionByKeys(healthByConnector = {}, initialSelected = []) {
917
+ async function askConnectorSelectionByKeys(healthByConnector = {}, initialSelected = [], copy = {}) {
563
918
  emitKeypressEvents(process.stdin);
564
919
  const wasRaw = process.stdin.isRaw;
565
920
  const wasPaused = process.stdin.isPaused();
566
921
  process.stdin.setRawMode(true);
567
922
  process.stdin.resume();
568
923
  let cursorIndex = 0;
569
- const required = getRequiredConnectorKeys();
924
+ const required = copy.mode === 'input' ? new Set() : getRequiredConnectorKeys();
570
925
  const initial = new Set(initialSelected);
571
- const selected = new Set(CONNECTOR_KEYS.filter((key) => required.has(key) || initial.has(key) || !isConnectorLocallyConfigured(key)));
926
+ const selected = new Set(CONNECTOR_KEYS.filter((key) => required.has(key) ||
927
+ initial.has(key) ||
928
+ (copy.mode !== 'input' && !isConnectorLocallyConfigured(key))));
572
929
  let warning = '';
573
930
  return await new Promise((resolve, reject) => {
574
931
  const displayItems = () => connectorPickerDisplayItems(healthByConnector);
@@ -586,7 +943,7 @@ async function askConnectorSelectionByKeys(healthByConnector = {}, initialSelect
586
943
  required.forEach((key) => selected.add(key));
587
944
  if (selected.size === 0) {
588
945
  warning = 'No connectors selected. Select a connector to update or press Esc to cancel.';
589
- renderConnectorPicker(cursorIndex, selected, required, healthByConnector, warning);
946
+ renderConnectorPicker(cursorIndex, selected, required, healthByConnector, warning, copy);
590
947
  return;
591
948
  }
592
949
  cleanup();
@@ -596,7 +953,7 @@ async function askConnectorSelectionByKeys(healthByConnector = {}, initialSelect
596
953
  const cancel = () => {
597
954
  cleanup();
598
955
  process.stdout.write('\n');
599
- reject(new Error('Connector setup cancelled.'));
956
+ reject(new WizardAbortError('Connector setup cancelled.'));
600
957
  };
601
958
  const toggleCurrent = () => {
602
959
  const connector = selectedDisplayConnector();
@@ -671,11 +1028,11 @@ async function askConnectorSelectionByKeys(healthByConnector = {}, initialSelect
671
1028
  }
672
1029
  }
673
1030
  }
674
- renderConnectorPicker(cursorIndex, selected, required, healthByConnector, warning);
1031
+ renderConnectorPicker(cursorIndex, selected, required, healthByConnector, warning, copy);
675
1032
  };
676
1033
  process.stdin.on('keypress', onKeypress);
677
1034
  process.stdout.write(ANSI.hideCursor);
678
- renderConnectorPicker(cursorIndex, selected, required, healthByConnector, warning);
1035
+ renderConnectorPicker(cursorIndex, selected, required, healthByConnector, warning, copy);
679
1036
  });
680
1037
  }
681
1038
  async function commandExists(commandName) {
@@ -914,16 +1271,16 @@ function summarizeFailureFix(connector, blockers) {
914
1271
  if (/revoked|unauthorized|UNAUTHORIZED/i.test(combined)) {
915
1272
  return 'Paste a fresh AnalyticsCLI readonly CLI token in the wizard, then let setup retest.';
916
1273
  }
917
- return 'Verify the AnalyticsCLI token can list projects. Per-project query failures are reported as warnings and should not block connector setup.';
1274
+ return 'Verify the AnalyticsCLI token can list accessible projects. Per-project query failures are reported as warnings and should not block connector setup.';
918
1275
  }
919
1276
  if (connector === 'sentry') {
920
1277
  if (/404|Not Found/i.test(combined)) {
921
- return 'Rerun Sentry/GlitchTip setup and use the correct base URL + discovered org. If projects are discovered, accept/select those projects.';
1278
+ return 'Rerun Sentry/GlitchTip setup and use the correct base URL + visible org. Project scope stays unpinned and is resolved from app context later.';
922
1279
  }
923
- return 'Verify the Sentry/GlitchTip token, base URL, org, and project list, then rerun setup.';
1280
+ return 'Verify the Sentry/GlitchTip token, base URL, and org, then rerun setup.';
924
1281
  }
925
1282
  if (connector === 'github') {
926
- return 'Set project.githubRepo only if you want GitHub issue/PR delivery now; otherwise leave GitHub deferred.';
1283
+ return 'Verify the GitHub token. Repo scope is inferred from OPENCLAW_GITHUB_REPO, the local git remote, or runtime context.';
927
1284
  }
928
1285
  if (connector === 'revenuecat') {
929
1286
  return 'Paste a RevenueCat v2 secret API key with read-only project permissions, then rerun setup.';
@@ -1048,11 +1405,6 @@ function printSetupSuccess(payload) {
1048
1405
  process.stdout.write(`${payload.message}\n`);
1049
1406
  }
1050
1407
  }
1051
- function healthCheckFailures(payload) {
1052
- return Array.isArray(payload?.checks)
1053
- ? payload.checks.filter((check) => check?.status === 'fail')
1054
- : [];
1055
- }
1056
1408
  function connectorFromCheckName(name) {
1057
1409
  const value = String(name || '');
1058
1410
  if (value.includes('analytics') || value.includes('ANALYTICSCLI'))
@@ -1111,146 +1463,12 @@ function cleanHealthDetail(detail) {
1111
1463
  }
1112
1464
  return truncate(raw, 180);
1113
1465
  }
1114
- function actionForHealthFailure(failure, configPath) {
1115
- const name = String(failure?.name || '');
1116
- const detail = String(failure?.detail || '');
1117
- if (name === 'project:github-repo' || /project\.githubRepo/i.test(detail)) {
1118
- return `No action required for Sentry setup. Set project.githubRepo in ${configPath} only if you want GitHub issue/PR delivery now.`;
1119
- }
1120
- if (name.includes('analytics') || /ANALYTICSCLI|analytics/i.test(detail)) {
1121
- return 'Paste a fresh AnalyticsCLI readonly token, then let the wizard retest AnalyticsCLI.';
1122
- }
1123
- if (name.includes('sentry') || /Sentry|GlitchTip/i.test(detail)) {
1124
- return 'Only fix this if token, org, or base URL is missing or invalid.';
1125
- }
1126
- if (name.includes('github')) {
1127
- return 'Configure GitHub token/repo access, or leave GitHub delivery disabled.';
1128
- }
1129
- if (name.includes('revenuecat')) {
1130
- return 'Paste a RevenueCat v2 secret API key with read-only project permissions.';
1131
- }
1132
- if (name.includes('asc')) {
1133
- return 'Paste ASC API key details or rerun ASC setup when ready.';
1134
- }
1135
- return 'Use the connector setup flow below to refresh this configuration.';
1136
- }
1137
1466
  function isDeferredGitHubFailure(failure) {
1138
1467
  const name = String(failure?.name || '');
1139
1468
  const detail = String(failure?.detail || '');
1140
1469
  return (name === 'project:github-repo' ||
1141
1470
  (name === 'connection:github' && /project\.githubRepo|repo is missing|repo is not configured/i.test(detail)));
1142
1471
  }
1143
- function isDeferredSentryProjectFailure(failure) {
1144
- const name = String(failure?.name || '');
1145
- const detail = String(failure?.detail || '');
1146
- return name.includes('sentry') && /No Sentry projects configured/i.test(detail);
1147
- }
1148
- function summarizeHealthFailure(failure, configPath) {
1149
- const name = String(failure?.name || '');
1150
- const detail = String(failure?.detail || '');
1151
- const connector = connectorFromCheckName(`${name} ${detail}`) || 'setup';
1152
- if (connector === 'analytics' && /invalid token|unauthorized|token has been revoked/i.test(detail)) {
1153
- return {
1154
- connector,
1155
- status: 'token invalid or expired',
1156
- action: 'paste a fresh readonly token',
1157
- };
1158
- }
1159
- if (connector === 'sentry' && /No Sentry projects configured/i.test(detail)) {
1160
- return {
1161
- connector,
1162
- status: 'project scope deferred',
1163
- action: 'no user action; OpenClaw discovers visible projects from org + token',
1164
- };
1165
- }
1166
- if (connector === 'github' && isDeferredGitHubFailure(failure)) {
1167
- return {
1168
- connector,
1169
- status: 'repo not known yet',
1170
- action: `optional; set project.githubRepo in ${configPath} only for GitHub delivery`,
1171
- };
1172
- }
1173
- return {
1174
- connector,
1175
- status: cleanHealthDetail(detail),
1176
- action: actionForHealthFailure(failure, configPath),
1177
- };
1178
- }
1179
- function printHealthFailures(failures, configPath) {
1180
- const summarized = [];
1181
- const seen = new Set();
1182
- for (const failure of failures) {
1183
- if (isDeferredGitHubFailure(failure))
1184
- continue;
1185
- if (isDeferredSentryProjectFailure(failure))
1186
- continue;
1187
- const summary = summarizeHealthFailure(failure, configPath);
1188
- const key = `${summary.connector}:${summary.status}:${summary.action}`;
1189
- if (seen.has(key))
1190
- continue;
1191
- seen.add(key);
1192
- summarized.push(summary);
1193
- }
1194
- if (summarized.length === 0) {
1195
- process.stdout.write('\nOnly deferred optional checks remain.\n\n');
1196
- return;
1197
- }
1198
- process.stdout.write('\nNeeds attention\n');
1199
- process.stdout.write('---------------\n');
1200
- for (const summary of summarized) {
1201
- process.stdout.write(`- ${connectorTitle(summary.connector)}: ${summary.status}\n`);
1202
- process.stdout.write(` Next: ${summary.action}\n`);
1203
- }
1204
- process.stdout.write('\n');
1205
- }
1206
- function inferConnectorsFromHealthFailures(failures) {
1207
- const inferred = new Set();
1208
- for (const failure of failures) {
1209
- if (isDeferredGitHubFailure(failure))
1210
- continue;
1211
- if (isDeferredSentryProjectFailure(failure))
1212
- continue;
1213
- const connector = connectorFromCheckName(`${failure?.name || ''} ${failure?.detail || ''}`);
1214
- if (connector)
1215
- inferred.add(connector);
1216
- }
1217
- return orderConnectors([...inferred]);
1218
- }
1219
- async function getHealthCheckPlan(configPath, selected) {
1220
- const config = await readJsonIfPresent(configPath).catch(() => null);
1221
- const items = [
1222
- {
1223
- key: 'preflight',
1224
- label: 'Local preflight',
1225
- detail: 'config, dependencies, source wiring',
1226
- status: 'pending',
1227
- },
1228
- ];
1229
- const selectedSet = new Set(selected);
1230
- const hasAnalytics = selectedSet.has('analytics') ||
1231
- Boolean(process.env.ANALYTICSCLI_ACCESS_TOKEN?.trim() || process.env.ANALYTICSCLI_READONLY_TOKEN?.trim()) ||
1232
- (config?.sources?.analytics && config.sources.analytics.enabled !== false);
1233
- const sentryAccounts = Array.isArray(config?.sources?.sentry?.accounts) ? config.sources.sentry.accounts : [];
1234
- const hasSentry = selectedSet.has('sentry') ||
1235
- sentryAccounts.length > 0 ||
1236
- Boolean(process.env.SENTRY_AUTH_TOKEN?.trim() || process.env.GLITCHTIP_AUTH_TOKEN?.trim());
1237
- const hasRevenueCat = selectedSet.has('revenuecat') ||
1238
- Boolean(process.env.REVENUECAT_API_KEY?.trim()) ||
1239
- (config?.sources?.revenuecat && config.sources.revenuecat.enabled !== false);
1240
- const githubRepo = String(config?.project?.githubRepo || '').trim();
1241
- const hasGitHub = selectedSet.has('github') || Boolean(process.env.GITHUB_TOKEN?.trim()) || Boolean(githubRepo);
1242
- if (hasAnalytics)
1243
- items.push({ key: 'analytics', label: 'AnalyticsCLI', detail: 'token auth + readonly query', status: 'pending' });
1244
- if (hasSentry)
1245
- items.push({ key: 'sentry', label: 'Sentry / GlitchTip', detail: 'token/org API + project discovery', status: 'pending' });
1246
- if (hasRevenueCat)
1247
- items.push({ key: 'revenuecat', label: 'RevenueCat', detail: 'API key auth + project read', status: 'pending' });
1248
- if (hasGitHub && githubRepo)
1249
- items.push({ key: 'github', label: 'GitHub', detail: `repo access (${githubRepo})`, status: 'pending' });
1250
- if (hasGitHub && !githubRepo)
1251
- items.push({ key: 'github', label: 'GitHub', detail: 'skipped until repo is known', status: 'pending' });
1252
- return items;
1253
- }
1254
1472
  function healthStatusLabel(status) {
1255
1473
  if (status === 'running')
1256
1474
  return 'running';
@@ -1299,9 +1517,6 @@ function updateHealthProgress(items, event) {
1299
1517
  }
1300
1518
  return false;
1301
1519
  }
1302
- function allProgressItemsFinished(items) {
1303
- return items.length > 0 && items.every((item) => !['pending', 'running'].includes(String(item.status || '')));
1304
- }
1305
1520
  function buildSetupTestProgressPlan(selected) {
1306
1521
  const selectedSet = new Set(selected);
1307
1522
  const items = [
@@ -1403,7 +1618,7 @@ async function runImmediateConnectorHealthCheck({ rl, configPath, connector, sec
1403
1618
  ...process.env,
1404
1619
  ...secrets,
1405
1620
  };
1406
- const command = `node scripts/openclaw-growth-start.mjs --config ${quote(configPath)} --setup-only --connectors ${quote(connector)} --only-connectors ${quote(connector)}`;
1621
+ const command = `${nodeRuntimeScriptCommand('openclaw-growth-start.mjs')} --config ${quote(configPath)} --setup-only --connectors ${quote(connector)} --only-connectors ${quote(connector)}`;
1407
1622
  let result = await runSetupCommandWithProgress(command, env, [connector], `Checking ${connectorLabel(connector)} immediately after setup...`);
1408
1623
  let payload = parseJsonFromStdout(result.stdout);
1409
1624
  if (connector === 'asc') {
@@ -1430,39 +1645,6 @@ async function runImmediateConnectorHealthCheck({ rl, configPath, connector, sec
1430
1645
  process.stdout.write(`\n${connectorLabel(connector)} immediate health check passed or is only waiting on optional/deferred context.\n`);
1431
1646
  return { ok: true, retry: false, result, payload };
1432
1647
  }
1433
- async function offerConfiguredConnectionFixes(rl, configPath, selected) {
1434
- if (!(await fileExists(configPath)))
1435
- return selected;
1436
- clearTerminal();
1437
- const plan = await getHealthCheckPlan(configPath, selected);
1438
- renderHealthProgress(plan, 'Starting live checks...');
1439
- const command = `node scripts/openclaw-growth-preflight.mjs --config ${quote(configPath)} --test-connections --progress-json`;
1440
- const result = await runCommandCaptureWithProgress(command, (event) => {
1441
- if (updateHealthProgress(plan, event)) {
1442
- renderHealthProgress(plan);
1443
- }
1444
- });
1445
- renderHealthProgress(plan, 'Checks finished.');
1446
- const payload = parseJsonFromStdout(result.stdout);
1447
- const failures = healthCheckFailures(payload).filter((failure) => !isDeferredGitHubFailure(failure) && !isDeferredSentryProjectFailure(failure));
1448
- if (payload?.ok === true || failures.length === 0) {
1449
- process.stdout.write('Configured connectors look healthy.\n\n');
1450
- return selected;
1451
- }
1452
- printHealthFailures(failures, configPath);
1453
- const inferred = inferConnectorsFromHealthFailures(failures);
1454
- if (inferred.length === 0) {
1455
- process.stdout.write('Continuing with the connector(s) you selected.\n\n');
1456
- return selected;
1457
- }
1458
- const fixNow = await askYesNo(rl, `Fix now (${inferred.join(', ')})?`, true);
1459
- clearTerminal();
1460
- if (!fixNow) {
1461
- process.stdout.write('Continuing with selected connector(s).\n\n');
1462
- return selected;
1463
- }
1464
- return orderConnectors([...new Set([...selected, ...inferred])]);
1465
- }
1466
1648
  function getUserLocalBinDir() {
1467
1649
  return process.env.HOME ? path.join(process.env.HOME, '.local', 'bin') : null;
1468
1650
  }
@@ -1818,13 +2000,13 @@ function getGrowthRunCommand(config, displayConfigPath) {
1818
2000
  if (config?.security?.connectorSecrets?.mode === 'isolated-runner' && config.security.connectorSecrets.runCommand) {
1819
2001
  return config.security.connectorSecrets.runCommand;
1820
2002
  }
1821
- return `node scripts/openclaw-growth-runner.mjs --config ${displayConfigPath}`;
2003
+ return growthEngineerPackageCommand(`run --config ${quote(displayConfigPath)}`);
1822
2004
  }
1823
2005
  function getConnectorHealthCommand(config, displayConfigPath) {
1824
2006
  if (config?.security?.connectorSecrets?.mode === 'isolated-runner' && config.security.connectorSecrets.healthCommand) {
1825
2007
  return config.security.connectorSecrets.healthCommand;
1826
2008
  }
1827
- return `node scripts/openclaw-growth-runner.mjs --config ${displayConfigPath}`;
2009
+ return growthEngineerPackageCommand(`run --config ${quote(displayConfigPath)}`);
1828
2010
  }
1829
2011
  async function maybePromptSecret(rl, label, envName) {
1830
2012
  const existing = process.env[envName]?.trim();
@@ -1874,12 +2056,6 @@ function printSentryTokenGuidance({ baseUrl, tokenEnv }) {
1874
2056
  'Optional for richer release context: `project:releases`.',
1875
2057
  ]);
1876
2058
  }
1877
- function parseCommaList(value) {
1878
- return String(value || '')
1879
- .split(',')
1880
- .map((entry) => entry.trim())
1881
- .filter(Boolean);
1882
- }
1883
2059
  function buildUrl(baseUrl, pathname, params = {}) {
1884
2060
  const url = new URL(pathname, `${String(baseUrl || 'https://sentry.io').replace(/\/$/, '')}/`);
1885
2061
  for (const [key, value] of Object.entries(params)) {
@@ -1904,7 +2080,7 @@ function apiListItems(payload) {
1904
2080
  return payload.teams;
1905
2081
  return [];
1906
2082
  }
1907
- async function fetchSentryJsonPage({ baseUrl, token, url }) {
2083
+ async function fetchSentryJsonPage({ token, url }) {
1908
2084
  const normalizedToken = String(token || '').trim();
1909
2085
  const response = await fetch(url, {
1910
2086
  method: 'GET',
@@ -1938,7 +2114,7 @@ async function fetchSentryJsonList({ baseUrl, token, url }) {
1938
2114
  const pages = [];
1939
2115
  let nextUrl = url;
1940
2116
  for (let page = 0; nextUrl && page < 10; page += 1) {
1941
- const result = await fetchSentryJsonPage({ baseUrl, token, url: nextUrl });
2117
+ const result = await fetchSentryJsonPage({ token, url: nextUrl });
1942
2118
  pages.push(result.detail);
1943
2119
  if (!result.ok)
1944
2120
  return { ...result, payload: items, detail: pages.join('; ') };
@@ -2069,7 +2245,7 @@ async function upsertSentryAccountsConfig(configPath, accounts) {
2069
2245
  ...(config.sources?.sentry || {}),
2070
2246
  enabled: true,
2071
2247
  mode: 'command',
2072
- command: getDefaultSourceCommand('sentry'),
2248
+ command: getWizardDefaultSourceCommand('sentry'),
2073
2249
  accounts: [...merged.values()],
2074
2250
  },
2075
2251
  };
@@ -2482,13 +2658,13 @@ async function guideSentryConnector(rl, secrets) {
2482
2658
  org = await ask(rl, `Sentry org slug for ${label} (leave empty to defer)`, index === 0 ? process.env.SENTRY_ORG || '' : '');
2483
2659
  }
2484
2660
  const environment = await ask(rl, `Sentry environment for ${label}`, index === 0 ? process.env.SENTRY_ENVIRONMENT || 'production' : 'production');
2485
- let projects = [];
2486
2661
  if (org.trim() && token) {
2487
- process.stdout.write(`Discovering Sentry projects for ${label}...\n`);
2662
+ process.stdout.write(`Checking visible Sentry projects for ${label} without pinning project scope...\n`);
2488
2663
  const discovery = await discoverSentryProjects({ baseUrl, token, org });
2664
+ let verifiedVisibleProjects = false;
2489
2665
  if (discovery.ok && discovery.projects.length > 0) {
2490
- projects = discovery.projects;
2491
- process.stdout.write(`Configured ${projects.length} project(s): ${projects.slice(0, 8).join(', ')}${projects.length > 8 ? ', ...' : ''}\n`);
2666
+ verifiedVisibleProjects = true;
2667
+ process.stdout.write(`Found ${discovery.projects.length} visible project(s). Project scope remains unpinned so OpenClaw/Hermes can decide per run.\n`);
2492
2668
  }
2493
2669
  else {
2494
2670
  const fallbackOrgs = discoveredOrganizations
@@ -2498,15 +2674,14 @@ async function guideSentryConnector(rl, secrets) {
2498
2674
  process.stdout.write(`Trying visible org ${fallbackOrg}...\n`);
2499
2675
  const fallbackDiscovery = await discoverSentryProjects({ baseUrl, token, org: fallbackOrg });
2500
2676
  if (fallbackDiscovery.ok && fallbackDiscovery.projects.length > 0) {
2501
- projects = fallbackDiscovery.projects;
2502
- process.stdout.write(`Using org ${fallbackOrg}; configured ${projects.length} project(s): ${projects.slice(0, 8).join(', ')}${projects.length > 8 ? ', ...' : ''}\n`);
2677
+ org = fallbackOrg;
2678
+ verifiedVisibleProjects = true;
2679
+ process.stdout.write(`Using org ${fallbackOrg}; found ${fallbackDiscovery.projects.length} visible project(s). Project scope remains unpinned.\n`);
2503
2680
  break;
2504
2681
  }
2505
2682
  }
2506
- if (projects.length === 0) {
2507
- process.stdout.write(`Could not discover projects automatically (${discovery.detail}).\n`);
2508
- const manualProjects = parseCommaList(await ask(rl, `Project slugs for ${label} (comma-separated, leave empty to let app context decide)`, ''));
2509
- projects = manualProjects;
2683
+ if (!verifiedVisibleProjects && !discovery.ok) {
2684
+ process.stdout.write(`Could not verify visible projects automatically (${discovery.detail}). Project scope will be resolved from app context later.\n`);
2510
2685
  }
2511
2686
  }
2512
2687
  }
@@ -2519,7 +2694,6 @@ async function guideSentryConnector(rl, secrets) {
2519
2694
  baseUrl,
2520
2695
  tokenEnv,
2521
2696
  ...(org.trim() ? { org: org.trim() } : {}),
2522
- ...(projects.length > 0 ? { projects } : {}),
2523
2697
  ...(environment.trim() ? { environment: environment.trim() } : {}),
2524
2698
  });
2525
2699
  if (index === 0) {
@@ -2679,6 +2853,144 @@ async function maybeSelfUpdateFromClawHub(args) {
2679
2853
  const code = await rerunCurrentWizardWithoutSelfUpdate();
2680
2854
  process.exit(code ?? 0);
2681
2855
  }
2856
+ async function runConnectorSetupSteps({ rl, args, selected, healthByConnector, allowIsolationPrompt = true, }) {
2857
+ clearTerminal();
2858
+ printConnectorIntro();
2859
+ process.stdout.write(`${ANSI.bold}Selected connectors${ANSI.reset}\n`);
2860
+ for (const key of selected) {
2861
+ process.stdout.write(` - ${connectorLabel(key)}\n`);
2862
+ }
2863
+ process.stdout.write('\n');
2864
+ const secrets = {};
2865
+ let sentryAccounts = [];
2866
+ if (selected.includes('analytics')) {
2867
+ let forceFreshAnalyticsToken = shouldForceFreshAnalyticsToken(healthByConnector);
2868
+ while (true) {
2869
+ clearTerminal();
2870
+ await guideAnalyticsConnector(rl, secrets, { forceFresh: forceFreshAnalyticsToken });
2871
+ const check = await runImmediateConnectorHealthCheck({
2872
+ rl,
2873
+ configPath: args.config,
2874
+ connector: 'analytics',
2875
+ secrets,
2876
+ });
2877
+ if (!check.retry)
2878
+ break;
2879
+ forceFreshAnalyticsToken = true;
2880
+ }
2881
+ }
2882
+ if (selected.includes('github')) {
2883
+ while (true) {
2884
+ clearTerminal();
2885
+ await guideGitHubConnector(rl, secrets);
2886
+ const check = await runImmediateConnectorHealthCheck({
2887
+ rl,
2888
+ configPath: args.config,
2889
+ connector: 'github',
2890
+ secrets,
2891
+ });
2892
+ if (!check.retry)
2893
+ break;
2894
+ }
2895
+ }
2896
+ if (selected.includes('revenuecat')) {
2897
+ while (true) {
2898
+ clearTerminal();
2899
+ await guideRevenueCatConnector(rl, secrets);
2900
+ const check = await runImmediateConnectorHealthCheck({
2901
+ rl,
2902
+ configPath: args.config,
2903
+ connector: 'revenuecat',
2904
+ secrets,
2905
+ });
2906
+ if (!check.retry)
2907
+ break;
2908
+ }
2909
+ }
2910
+ if (selected.includes('sentry')) {
2911
+ while (true) {
2912
+ clearTerminal();
2913
+ sentryAccounts = await guideSentryConnector(rl, secrets);
2914
+ const check = await runImmediateConnectorHealthCheck({
2915
+ rl,
2916
+ configPath: args.config,
2917
+ connector: 'sentry',
2918
+ secrets,
2919
+ sentryAccounts,
2920
+ });
2921
+ if (!check.retry)
2922
+ break;
2923
+ }
2924
+ }
2925
+ if (selected.includes('asc')) {
2926
+ while (true) {
2927
+ clearTerminal();
2928
+ await guideAscConnector(rl, secrets);
2929
+ const check = await runImmediateConnectorHealthCheck({
2930
+ rl,
2931
+ configPath: args.config,
2932
+ connector: 'asc',
2933
+ secrets,
2934
+ });
2935
+ if (!check.retry)
2936
+ break;
2937
+ }
2938
+ }
2939
+ const secretsFile = resolveSecretsFile();
2940
+ const wroteSecrets = Object.keys(secrets).length > 0;
2941
+ clearTerminal();
2942
+ if (wroteSecrets) {
2943
+ await writeSecretsFile(secretsFile, secrets);
2944
+ process.stdout.write(`\nSaved local secrets to ${secretsFile} with chmod 600.\n`);
2945
+ }
2946
+ else {
2947
+ process.stdout.write('\nNo new secrets were written.\n');
2948
+ }
2949
+ if (sentryAccounts.length > 0 && await upsertSentryAccountsConfig(args.config, sentryAccounts)) {
2950
+ process.stdout.write(`Configured ${sentryAccounts.length} Sentry-compatible account(s) in ${args.config}.\n`);
2951
+ }
2952
+ const env = {
2953
+ ...process.env,
2954
+ ...secrets,
2955
+ };
2956
+ const command = `${nodeRuntimeScriptCommand('openclaw-growth-start.mjs')} --config ${quote(args.config)} --setup-only --connectors ${quote(selected.join(','))}`;
2957
+ let setupResult = await runSetupCommandWithProgress(command, env, selected, 'Testing connector setup...');
2958
+ let setupPayload = parseJsonFromStdout(setupResult.stdout);
2959
+ if (sentryAccounts.length > 0 && await upsertSentryAccountsConfig(args.config, sentryAccounts)) {
2960
+ process.stdout.write(`Sentry-compatible account config is up to date in ${args.config}.\n`);
2961
+ }
2962
+ if (selected.includes('asc')) {
2963
+ try {
2964
+ const ascWebAuthChanged = await ensureAscWebAnalyticsAuth(rl, secrets);
2965
+ if (ascWebAuthChanged) {
2966
+ setupResult = await runSetupCommandWithProgress(command, env, selected, 'Retesting connector setup after ASC web analytics login...');
2967
+ setupPayload = parseJsonFromStdout(setupResult.stdout);
2968
+ }
2969
+ }
2970
+ catch (error) {
2971
+ process.stdout.write(`ASC web analytics still needs attention: ${error instanceof Error ? error.message : String(error)}\n`);
2972
+ }
2973
+ }
2974
+ if (setupResult.ok && setupPayload?.ok !== false) {
2975
+ printSetupSuccess(setupPayload);
2976
+ if (wroteSecrets) {
2977
+ process.stdout.write('Future OpenClaw Growth commands load this secrets file automatically.\n');
2978
+ }
2979
+ const configureIsolation = allowIsolationPrompt && ENABLE_ISOLATED_SECRET_RUNNER_WIZARD && await askYesNo(rl, 'Generate an isolated secret runner so OpenClaw can run health checks without reading API keys?', true);
2980
+ if (configureIsolation) {
2981
+ const config = await loadEditableConfig(args.config);
2982
+ const secretAccess = await askSecretAccessModel(rl, path.resolve(args.config), config);
2983
+ await writeJsonFile(path.resolve(args.config), config);
2984
+ const manifestPath = await writeOpenClawJobManifest(path.resolve(args.config), config);
2985
+ process.stdout.write(`Saved OpenClaw job manifest: ${manifestPath}\n`);
2986
+ printSecretRunnerKitInstructions(secretAccess.kit);
2987
+ }
2988
+ return true;
2989
+ }
2990
+ printSetupFailure({ result: setupResult, payload: setupPayload, command });
2991
+ process.exitCode = 1;
2992
+ return false;
2993
+ }
2682
2994
  async function runConnectorSetupWizard(args) {
2683
2995
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
2684
2996
  throw new Error('Connector wizard requires an interactive terminal.');
@@ -2687,151 +2999,18 @@ async function runConnectorSetupWizard(args) {
2687
2999
  try {
2688
3000
  clearTerminal();
2689
3001
  printConnectorIntro();
3002
+ await migrateRuntimeSourceCommandsFile(args.config);
2690
3003
  const healthByConnector = await withConnectorHealthLoading((onProgress) => getConnectorPickerHealth(args.config, onProgress));
2691
3004
  const existingFixes = connectorKeysNeedingAttention(healthByConnector);
2692
3005
  const requestedConnectors = args.connectors ? parseConnectorList(args.connectors) : [];
2693
3006
  const chosenConnectors = requestedConnectors.length > 0
2694
3007
  ? orderConnectors([...new Set([...requestedConnectors, ...existingFixes])])
2695
3008
  : await askConnectorSelectionWithHealth(rl, healthByConnector, existingFixes);
2696
- let selected = withMissingRequiredAnalyticsConnector(chosenConnectors);
3009
+ const selected = withMissingRequiredAnalyticsConnector(chosenConnectors);
2697
3010
  if (selected.length === 0) {
2698
3011
  throw new Error('No supported connectors selected. Use analytics, github, revenuecat, sentry, asc, or all.');
2699
3012
  }
2700
- clearTerminal();
2701
- printConnectorIntro();
2702
- process.stdout.write(`${ANSI.bold}Selected connectors${ANSI.reset}\n`);
2703
- for (const key of selected) {
2704
- process.stdout.write(` - ${connectorLabel(key)}\n`);
2705
- }
2706
- process.stdout.write('\n');
2707
- const secrets = {};
2708
- let sentryAccounts = [];
2709
- if (selected.includes('analytics')) {
2710
- let forceFreshAnalyticsToken = shouldForceFreshAnalyticsToken(healthByConnector);
2711
- while (true) {
2712
- clearTerminal();
2713
- await guideAnalyticsConnector(rl, secrets, { forceFresh: forceFreshAnalyticsToken });
2714
- const check = await runImmediateConnectorHealthCheck({
2715
- rl,
2716
- configPath: args.config,
2717
- connector: 'analytics',
2718
- secrets,
2719
- });
2720
- if (!check.retry)
2721
- break;
2722
- forceFreshAnalyticsToken = true;
2723
- }
2724
- }
2725
- if (selected.includes('github')) {
2726
- while (true) {
2727
- clearTerminal();
2728
- await guideGitHubConnector(rl, secrets);
2729
- const check = await runImmediateConnectorHealthCheck({
2730
- rl,
2731
- configPath: args.config,
2732
- connector: 'github',
2733
- secrets,
2734
- });
2735
- if (!check.retry)
2736
- break;
2737
- }
2738
- }
2739
- if (selected.includes('revenuecat')) {
2740
- while (true) {
2741
- clearTerminal();
2742
- await guideRevenueCatConnector(rl, secrets);
2743
- const check = await runImmediateConnectorHealthCheck({
2744
- rl,
2745
- configPath: args.config,
2746
- connector: 'revenuecat',
2747
- secrets,
2748
- });
2749
- if (!check.retry)
2750
- break;
2751
- }
2752
- }
2753
- if (selected.includes('sentry')) {
2754
- while (true) {
2755
- clearTerminal();
2756
- sentryAccounts = await guideSentryConnector(rl, secrets);
2757
- const check = await runImmediateConnectorHealthCheck({
2758
- rl,
2759
- configPath: args.config,
2760
- connector: 'sentry',
2761
- secrets,
2762
- sentryAccounts,
2763
- });
2764
- if (!check.retry)
2765
- break;
2766
- }
2767
- }
2768
- if (selected.includes('asc')) {
2769
- while (true) {
2770
- clearTerminal();
2771
- await guideAscConnector(rl, secrets);
2772
- const check = await runImmediateConnectorHealthCheck({
2773
- rl,
2774
- configPath: args.config,
2775
- connector: 'asc',
2776
- secrets,
2777
- });
2778
- if (!check.retry)
2779
- break;
2780
- }
2781
- }
2782
- const secretsFile = resolveSecretsFile();
2783
- const wroteSecrets = Object.keys(secrets).length > 0;
2784
- clearTerminal();
2785
- if (wroteSecrets) {
2786
- await writeSecretsFile(secretsFile, secrets);
2787
- process.stdout.write(`\nSaved local secrets to ${secretsFile} with chmod 600.\n`);
2788
- }
2789
- else {
2790
- process.stdout.write('\nNo new secrets were written.\n');
2791
- }
2792
- if (sentryAccounts.length > 0 && await upsertSentryAccountsConfig(args.config, sentryAccounts)) {
2793
- process.stdout.write(`Configured ${sentryAccounts.length} Sentry-compatible account(s) in ${args.config}.\n`);
2794
- }
2795
- const env = {
2796
- ...process.env,
2797
- ...secrets,
2798
- };
2799
- const command = `node scripts/openclaw-growth-start.mjs --config ${quote(args.config)} --setup-only --connectors ${quote(selected.join(','))}`;
2800
- let setupResult = await runSetupCommandWithProgress(command, env, selected, 'Testing connector setup...');
2801
- let setupPayload = parseJsonFromStdout(setupResult.stdout);
2802
- if (sentryAccounts.length > 0 && await upsertSentryAccountsConfig(args.config, sentryAccounts)) {
2803
- process.stdout.write(`Sentry-compatible account config is up to date in ${args.config}.\n`);
2804
- }
2805
- if (selected.includes('asc')) {
2806
- try {
2807
- const ascWebAuthChanged = await ensureAscWebAnalyticsAuth(rl, secrets);
2808
- if (ascWebAuthChanged) {
2809
- setupResult = await runSetupCommandWithProgress(command, env, selected, 'Retesting connector setup after ASC web analytics login...');
2810
- setupPayload = parseJsonFromStdout(setupResult.stdout);
2811
- }
2812
- }
2813
- catch (error) {
2814
- process.stdout.write(`ASC web analytics still needs attention: ${error instanceof Error ? error.message : String(error)}\n`);
2815
- }
2816
- }
2817
- if (setupResult.ok && setupPayload?.ok !== false) {
2818
- printSetupSuccess(setupPayload);
2819
- if (wroteSecrets) {
2820
- process.stdout.write('Future OpenClaw Growth commands load this secrets file automatically.\n');
2821
- }
2822
- const configureIsolation = ENABLE_ISOLATED_SECRET_RUNNER_WIZARD && await askYesNo(rl, 'Generate an isolated secret runner so OpenClaw can run health checks without reading API keys?', true);
2823
- if (configureIsolation) {
2824
- const config = await loadEditableConfig(args.config);
2825
- const secretAccess = await askSecretAccessModel(rl, path.resolve(args.config), config);
2826
- await writeJsonFile(path.resolve(args.config), config);
2827
- const manifestPath = await writeOpenClawJobManifest(path.resolve(args.config), config);
2828
- process.stdout.write(`Saved OpenClaw job manifest: ${manifestPath}\n`);
2829
- printSecretRunnerKitInstructions(secretAccess.kit);
2830
- }
2831
- return;
2832
- }
2833
- printSetupFailure({ result: setupResult, payload: setupPayload, command });
2834
- process.exitCode = 1;
3013
+ await runConnectorSetupSteps({ rl, args, selected, healthByConnector });
2835
3014
  }
2836
3015
  finally {
2837
3016
  rl.close();
@@ -2870,89 +3049,89 @@ async function askYesNo(rl, label, defaultYes = true) {
2870
3049
  }
2871
3050
  }
2872
3051
  }
2873
- async function askChoice(rl, label, options, defaultValue) {
2874
- const normalizedDefault = options.includes(defaultValue) ? defaultValue : options[0];
2875
- while (true) {
2876
- const answer = (await rl.question(`${label} (${options.join('/')}) [${normalizedDefault}]: `))
2877
- .trim()
2878
- .toLowerCase();
2879
- if (!answer) {
2880
- return normalizedDefault;
2881
- }
2882
- if (options.includes(answer)) {
2883
- return answer;
2884
- }
2885
- }
2886
- }
2887
- async function askSourceConfig(rl, sourceName, defaultPath, hint, options = {}) {
2888
- const forceEnabled = Boolean(options.forceEnabled);
2889
- const defaultCommand = String(options.defaultCommand || getDefaultSourceCommand(sourceName) || '').trim();
2890
- const defaultMode = defaultCommand ? 'command' : 'file';
2891
- const defaultEnabled = options.defaultEnabled ?? sourceName === 'analytics';
2892
- const enabled = forceEnabled
2893
- ? true
2894
- : await askYesNo(rl, `Enable source "${sourceName}"?`, defaultEnabled);
2895
- if (!enabled) {
2896
- return {
2897
- enabled: false,
2898
- mode: 'file',
2899
- path: defaultPath,
2900
- hint,
2901
- };
2902
- }
2903
- process.stdout.write(`Where to get ${sourceName} data:\n${hint}\n`);
2904
- const modeInput = await ask(rl, 'Mode (file/command)', defaultMode);
2905
- const mode = modeInput.toLowerCase() === 'command' ? 'command' : 'file';
2906
- const value = await ask(rl, mode === 'file' ? `${sourceName} JSON file path` : `${sourceName} command`, mode === 'file' ? defaultPath : defaultCommand);
2907
- if (mode === 'file') {
2908
- return {
2909
- enabled: true,
2910
- mode,
2911
- path: value,
2912
- hint,
2913
- };
3052
+ function truncateTableCell(value, width) {
3053
+ const text = String(value || '').replace(/\s+/g, ' ').trim();
3054
+ if (text.length <= width)
3055
+ return text.padEnd(width, ' ');
3056
+ return `${text.slice(0, Math.max(0, width - 3))}...`.padEnd(width, ' ');
3057
+ }
3058
+ function printAsciiTable(headers, rows, widths) {
3059
+ const border = `+${widths.map((width) => '-'.repeat(width + 2)).join('+')}+`;
3060
+ const renderRow = (cells) => `| ${cells.map((cell, index) => truncateTableCell(cell, widths[index])).join(' | ')} |`;
3061
+ process.stdout.write(`${border}\n`);
3062
+ process.stdout.write(`${renderRow(headers)}\n`);
3063
+ process.stdout.write(`${border}\n`);
3064
+ for (const row of rows) {
3065
+ process.stdout.write(`${renderRow(row)}\n`);
2914
3066
  }
2915
- return {
2916
- enabled: true,
2917
- mode,
2918
- command: value,
2919
- hint,
2920
- ...(options.cursorMode ? { cursorMode: options.cursorMode } : {}),
2921
- ...(options.initialLookback ? { initialLookback: options.initialLookback } : {}),
2922
- };
3067
+ process.stdout.write(`${border}\n`);
2923
3068
  }
2924
3069
  function printCadencePlan(cadences) {
2925
3070
  process.stdout.write('\nDefault growth cadence:\n');
2926
- for (const cadence of cadences) {
2927
- const critical = cadence.criticalOnly ? 'critical only' : 'full review';
2928
- process.stdout.write(`- ${cadence.title} (${critical}): ${cadence.objective}\n`);
2929
- }
3071
+ printAsciiTable(['Cadence', 'Every', 'Mode', 'Primary focus', 'What it decides'], cadences.map((cadence) => [
3072
+ cadence.key,
3073
+ `${cadence.intervalDays}d`,
3074
+ cadence.criticalOnly ? 'critical only' : 'full review',
3075
+ Array.isArray(cadence.focusAreas) ? cadence.focusAreas.slice(0, 4).join(', ') : '',
3076
+ cadence.objective,
3077
+ ]), [12, 7, 13, 30, 42]);
2930
3078
  process.stdout.write('\n');
2931
3079
  }
2932
3080
  async function askToolUsage(rl) {
2933
- process.stdout.write('\nHow should OpenClaw Growth Engineer use this tool?\n');
2934
- process.stdout.write(' 1) Production autopilot: notify, draft issues/PR handoffs, and analyze on schedule\n');
2935
- process.stdout.write(' 2) Advisory only: analyze and write OpenClaw chat summaries, no GitHub artifacts by default\n');
2936
- process.stdout.write(' 3) Manual reports: mostly one-off runs; keep scheduling conservative\n');
2937
- const answer = await ask(rl, 'Usage mode (1/2/3)', '1');
2938
- if (answer.trim() === '2')
2939
- return 'advisory';
2940
- if (answer.trim() === '3')
2941
- return 'manual_reports';
2942
- return 'production_autopilot';
2943
- }
2944
- async function askCadencePlan(rl) {
2945
- const cadences = DEFAULT_CADENCE_PLAN.map((cadence) => ({ ...cadence }));
3081
+ return await askMenuChoice(rl, {
3082
+ title: 'How should OpenClaw Growth Engineer run?',
3083
+ subtitle: 'Use Up/Down to move, Enter to continue, or press 1-3.',
3084
+ defaultValue: 'production_autopilot',
3085
+ options: [
3086
+ {
3087
+ value: 'production_autopilot',
3088
+ label: 'Production autopilot',
3089
+ detail: 'Notify, draft issues/PR handoffs, and analyze on schedule.',
3090
+ },
3091
+ {
3092
+ value: 'advisory',
3093
+ label: 'Advisory only',
3094
+ detail: 'Analyze and write OpenClaw chat summaries; no GitHub artifacts by default.',
3095
+ },
3096
+ {
3097
+ value: 'manual_reports',
3098
+ label: 'Manual reports',
3099
+ detail: 'Mostly one-off runs with conservative scheduling.',
3100
+ },
3101
+ ],
3102
+ });
3103
+ }
3104
+ async function askCadencePlan(rl, existingCadences = []) {
3105
+ const existingByKey = new Map((Array.isArray(existingCadences) ? existingCadences : [])
3106
+ .filter((cadence) => cadence?.key)
3107
+ .map((cadence) => [String(cadence.key), cadence]));
3108
+ const cadences = DEFAULT_CADENCE_PLAN.map((cadence) => ({
3109
+ ...cadence,
3110
+ ...(existingByKey.get(cadence.key) || {}),
3111
+ }));
2946
3112
  printCadencePlan(cadences);
2947
- 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);
2948
- if (customize)
3113
+ const selectedCadences = await askMultiChoice(rl, {
3114
+ title: 'Scheduled review cadences',
3115
+ subtitle: 'Use Up/Down to move, Space to toggle cadences, A to toggle all, Enter to continue.',
3116
+ defaultValues: cadences.filter((cadence) => cadence.enabled !== false).map((cadence) => cadence.key),
3117
+ minSelections: 1,
3118
+ options: cadences.map((cadence) => ({
3119
+ value: cadence.key,
3120
+ label: cadence.title,
3121
+ detail: `${cadence.intervalDays}d, ${cadence.criticalOnly ? 'critical only' : 'full review'} - ${cadence.objective}`,
3122
+ })),
3123
+ });
3124
+ const selected = new Set(selectedCadences);
3125
+ cadences.forEach((cadence) => {
3126
+ cadence.enabled = selected.has(cadence.key);
3127
+ });
3128
+ const customize = await askYesNo(rl, 'Customize objectives, instructions, focus areas, or source priorities for enabled cadences?', false);
3129
+ if (!customize)
2949
3130
  return cadences;
2950
3131
  for (const cadence of cadences) {
2951
- process.stdout.write(`\n${cadence.title}\n`);
2952
- const enabled = await askYesNo(rl, `Enable ${cadence.key}?`, true);
2953
- cadence.enabled = enabled;
2954
- if (!enabled)
3132
+ if (cadence.enabled === false)
2955
3133
  continue;
3134
+ process.stdout.write(`\n${cadence.title}\n`);
2956
3135
  cadence.objective = await ask(rl, `${cadence.key} objective`, cadence.objective);
2957
3136
  cadence.instructions = await ask(rl, `${cadence.key} instructions`, cadence.instructions);
2958
3137
  const focusAreas = await ask(rl, `${cadence.key} focus areas (comma-separated)`, cadence.focusAreas.join(','));
@@ -2964,27 +3143,45 @@ async function askCadencePlan(rl) {
2964
3143
  return cadences;
2965
3144
  }
2966
3145
  async function askWizardGoal(rl) {
2967
- process.stdout.write('\nWhat do you want to configure?\n');
2968
- process.stdout.write(' 1) Full setup: project, schedule, outputs, and sources\n');
2969
- process.stdout.write(' 2) Connectors: credentials and provider health checks\n');
2970
- process.stdout.write(' 3) Intervals: growth cadence and connector health check interval\n');
2971
- process.stdout.write(' 4) Output: summary, GitHub issues, draft PRs, and notifications\n');
2972
- const answer = await ask(rl, 'Setup area (1/2/3/4)', '1');
2973
- if (answer.trim() === '2')
2974
- return 'connectors';
2975
- if (answer.trim() === '3')
2976
- return 'intervals';
2977
- if (answer.trim() === '4')
2978
- return 'output';
2979
- return 'full';
3146
+ return await askMenuChoice(rl, {
3147
+ title: 'What do you want to configure?',
3148
+ subtitle: 'Use Up/Down to move, Enter to continue, or press 1-4.',
3149
+ defaultValue: 'full',
3150
+ renderHeader: printWizardHeader,
3151
+ options: [
3152
+ {
3153
+ value: 'connectors',
3154
+ label: 'Connectors',
3155
+ detail: 'Credentials, provider setup, and health checks.',
3156
+ },
3157
+ {
3158
+ value: 'outputs_intervals',
3159
+ label: 'Outputs and intervals',
3160
+ detail: 'Daily/weekly/monthly jobs, GitHub issue/PR delivery, and OpenClaw chat notifications.',
3161
+ },
3162
+ {
3163
+ value: 'full',
3164
+ label: 'Full setup',
3165
+ detail: 'Project, connectors, outputs, intervals, and sources.',
3166
+ },
3167
+ {
3168
+ value: 'intervals',
3169
+ label: 'Advanced intervals only',
3170
+ detail: 'Runner wake-up interval and connector health check cadence.',
3171
+ },
3172
+ ],
3173
+ });
3174
+ }
3175
+ function printWizardHeader() {
3176
+ process.stdout.write('OpenClaw Growth Engineer - Setup Wizard\n');
3177
+ 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');
2980
3178
  }
2981
3179
  async function buildDefaultWizardConfig() {
2982
- const detectedRepo = await detectGitHubRepo();
2983
3180
  return {
2984
3181
  version: 7,
2985
3182
  generatedAt: new Date().toISOString(),
2986
3183
  project: {
2987
- githubRepo: detectedRepo || '',
3184
+ githubRepo: '',
2988
3185
  repoRoot: '.',
2989
3186
  outFile: 'data/openclaw-growth-engineer/issues.generated.json',
2990
3187
  maxIssues: 4,
@@ -2995,17 +3192,17 @@ async function buildDefaultWizardConfig() {
2995
3192
  analytics: {
2996
3193
  enabled: true,
2997
3194
  mode: 'command',
2998
- command: getDefaultSourceCommand('analytics'),
3195
+ command: getWizardDefaultSourceCommand('analytics'),
2999
3196
  },
3000
3197
  revenuecat: {
3001
3198
  enabled: false,
3002
3199
  mode: 'command',
3003
- command: getDefaultSourceCommand('revenuecat'),
3200
+ command: getWizardDefaultSourceCommand('revenuecat'),
3004
3201
  },
3005
3202
  sentry: {
3006
- enabled: false,
3203
+ enabled: true,
3007
3204
  mode: 'command',
3008
- command: getDefaultSourceCommand('sentry'),
3205
+ command: getWizardDefaultSourceCommand('sentry'),
3009
3206
  },
3010
3207
  feedback: {
3011
3208
  enabled: true,
@@ -3015,12 +3212,12 @@ async function buildDefaultWizardConfig() {
3015
3212
  initialLookback: '30d',
3016
3213
  },
3017
3214
  extra: [
3018
- buildExtraSourceConfig('asc-cli', { enabled: false, mode: 'command', command: getDefaultSourceCommand('asc') }),
3215
+ buildExtraSourceConfig('asc-cli', { enabled: false, mode: 'command', command: getWizardDefaultSourceCommand('asc') }),
3019
3216
  ],
3020
3217
  },
3021
3218
  schedule: {
3022
- intervalMinutes: 1440,
3023
- connectorHealthCheckIntervalMinutes: 720,
3219
+ intervalMinutes: DEFAULT_GROWTH_INTERVAL_MINUTES,
3220
+ connectorHealthCheckIntervalMinutes: DEFAULT_CONNECTOR_HEALTH_INTERVAL_MINUTES,
3024
3221
  skipIfNoDataChange: true,
3025
3222
  skipIfIssueSetUnchanged: true,
3026
3223
  cadences: DEFAULT_CADENCE_PLAN.map((cadence) => ({ ...cadence })),
@@ -3028,6 +3225,8 @@ async function buildDefaultWizardConfig() {
3028
3225
  actions: {
3029
3226
  autoCreateIssues: false,
3030
3227
  autoCreatePullRequests: false,
3228
+ autoCreateWhenGitHubWriteAccess: true,
3229
+ disableAutoCreateGitHubArtifacts: false,
3031
3230
  mode: 'issue',
3032
3231
  usageMode: 'production_autopilot',
3033
3232
  draftPullRequests: true,
@@ -3101,10 +3300,127 @@ async function buildDefaultWizardConfig() {
3101
3300
  },
3102
3301
  };
3103
3302
  }
3303
+ function buildRecommendedSourceConfig() {
3304
+ return {
3305
+ analytics: {
3306
+ enabled: true,
3307
+ mode: 'command',
3308
+ command: getWizardDefaultSourceCommand('analytics'),
3309
+ },
3310
+ revenuecat: {
3311
+ enabled: false,
3312
+ mode: 'command',
3313
+ command: getWizardDefaultSourceCommand('revenuecat'),
3314
+ },
3315
+ sentry: {
3316
+ enabled: true,
3317
+ mode: 'command',
3318
+ command: getWizardDefaultSourceCommand('sentry'),
3319
+ },
3320
+ feedback: {
3321
+ enabled: true,
3322
+ mode: 'command',
3323
+ command: getDefaultSourceCommand('feedback'),
3324
+ cursorMode: 'auto_since_last_fetch',
3325
+ initialLookback: '30d',
3326
+ },
3327
+ extra: [
3328
+ buildExtraSourceConfig('asc-cli', { enabled: false, mode: 'command', command: getWizardDefaultSourceCommand('asc') }),
3329
+ ],
3330
+ };
3331
+ }
3332
+ function getInputChannelInitialSelection(config) {
3333
+ const sources = config?.sources || {};
3334
+ const extraSources = Array.isArray(sources.extra) ? sources.extra : [];
3335
+ const selected = new Set();
3336
+ const hasExplicitSources = Boolean(config?.sources);
3337
+ if (!hasExplicitSources || sources.analytics?.enabled !== false)
3338
+ selected.add('analytics');
3339
+ if (sources.revenuecat?.enabled === true || isConnectorLocallyConfigured('revenuecat'))
3340
+ selected.add('revenuecat');
3341
+ if (!hasExplicitSources || sources.sentry?.enabled !== false)
3342
+ selected.add('sentry');
3343
+ if (extraSources.some((source) => ['asc', 'asc-cli', 'app-store-connect', 'app_store_connect'].includes(String(source?.service || source?.key || '').toLowerCase()) &&
3344
+ source?.enabled !== false) ||
3345
+ isConnectorLocallyConfigured('asc')) {
3346
+ selected.add('asc');
3347
+ }
3348
+ if (config?.deliveries?.github?.enabled ||
3349
+ config?.actions?.autoCreateIssues ||
3350
+ config?.actions?.autoCreatePullRequests ||
3351
+ isConnectorLocallyConfigured('github')) {
3352
+ selected.add('github');
3353
+ }
3354
+ return orderConnectors([...selected]);
3355
+ }
3356
+ function buildSourceConfigFromInputChannels(selectedConnectors, existingSources = {}) {
3357
+ const selected = new Set(selectedConnectors);
3358
+ const recommended = buildRecommendedSourceConfig();
3359
+ const migratedSources = migrateRuntimeSourceCommands({ sources: existingSources }).sources || {};
3360
+ const existingExtra = Array.isArray(migratedSources.extra) ? migratedSources.extra : [];
3361
+ const ascSource = existingExtra.find((source) => ['asc', 'asc-cli', 'app-store-connect', 'app_store_connect'].includes(String(source?.service || source?.key || '').toLowerCase()));
3362
+ const nonAscExtra = existingExtra.filter((source) => source !== ascSource);
3363
+ return {
3364
+ ...recommended,
3365
+ ...migratedSources,
3366
+ analytics: {
3367
+ ...recommended.analytics,
3368
+ ...(migratedSources.analytics || {}),
3369
+ command: normalizeWizardSourceCommand('analytics', {
3370
+ ...recommended.analytics,
3371
+ ...(migratedSources.analytics || {}),
3372
+ }),
3373
+ enabled: selected.has('analytics'),
3374
+ },
3375
+ revenuecat: {
3376
+ ...recommended.revenuecat,
3377
+ ...(migratedSources.revenuecat || {}),
3378
+ command: normalizeWizardSourceCommand('revenuecat', {
3379
+ ...recommended.revenuecat,
3380
+ ...(migratedSources.revenuecat || {}),
3381
+ }),
3382
+ enabled: selected.has('revenuecat'),
3383
+ },
3384
+ sentry: {
3385
+ ...recommended.sentry,
3386
+ ...(migratedSources.sentry || {}),
3387
+ command: normalizeWizardSourceCommand('sentry', {
3388
+ ...recommended.sentry,
3389
+ ...(migratedSources.sentry || {}),
3390
+ }),
3391
+ enabled: selected.has('sentry'),
3392
+ },
3393
+ feedback: {
3394
+ ...recommended.feedback,
3395
+ ...(migratedSources.feedback || {}),
3396
+ enabled: selected.has('analytics'),
3397
+ },
3398
+ extra: [
3399
+ ...nonAscExtra,
3400
+ {
3401
+ ...buildExtraSourceConfig('asc-cli', {
3402
+ enabled: selected.has('asc'),
3403
+ mode: 'command',
3404
+ command: getWizardDefaultSourceCommand('asc'),
3405
+ }),
3406
+ ...(ascSource || {}),
3407
+ command: normalizeWizardSourceCommand('asc', {
3408
+ ...buildExtraSourceConfig('asc-cli', {
3409
+ enabled: selected.has('asc'),
3410
+ mode: 'command',
3411
+ command: getWizardDefaultSourceCommand('asc'),
3412
+ }),
3413
+ ...(ascSource || {}),
3414
+ }),
3415
+ enabled: selected.has('asc'),
3416
+ },
3417
+ ],
3418
+ };
3419
+ }
3104
3420
  async function loadEditableConfig(configPath) {
3105
3421
  const existing = await readJsonIfPresent(configPath).catch(() => null);
3106
3422
  if (existing && typeof existing === 'object')
3107
- return existing;
3423
+ return migrateRuntimeSourceCommands(existing);
3108
3424
  return await buildDefaultWizardConfig();
3109
3425
  }
3110
3426
  function mergeNotificationChannels(baseChannels, extraChannels) {
@@ -3148,28 +3464,55 @@ async function askNotificationChannels(rl, config) {
3148
3464
  return channels;
3149
3465
  }
3150
3466
  async function askOutputConfig(rl, config) {
3151
- process.stdout.write('\nOutput type\n');
3152
- process.stdout.write(' 1) Summary only: OpenClaw chat handoff and notifications\n');
3153
- process.stdout.write(' 2) GitHub issue drafts: generate issue-ready handoffs, no auto-create by default\n');
3154
- process.stdout.write(' 3) GitHub pull request drafts: generate PR-oriented proposal branches when enabled\n');
3467
+ printSection('Outputs and notifications', [
3468
+ 'OpenClaw chat is always enabled so the agent has a readable handoff.',
3469
+ 'GitHub issues or draft PRs are optional and only run when a token plus an inferred repo are available.',
3470
+ ]);
3155
3471
  const currentMode = config?.actions?.mode || config?.deliveries?.github?.mode || 'issue';
3156
- const currentAutoCreate = Boolean(config?.actions?.autoCreateIssues || config?.actions?.autoCreatePullRequests || config?.deliveries?.github?.autoCreate);
3157
- const defaultChoice = currentAutoCreate ? (currentMode === 'pull_request' ? '3' : '2') : '1';
3158
- const outputChoice = await ask(rl, 'Output type (1/2/3)', defaultChoice);
3159
- const summaryOnly = outputChoice.trim() === '1';
3160
- const mode = outputChoice.trim() === '3' ? 'pull_request' : 'issue';
3161
- const autoCreate = summaryOnly
3162
- ? false
3163
- : await askYesNo(rl, mode === 'pull_request'
3164
- ? 'Automatically create draft pull requests when new findings are found?'
3165
- : 'Automatically create GitHub issues when new findings are found?', currentAutoCreate);
3472
+ const configuredDestinations = Array.isArray(config?.actions?.outputDestinations)
3473
+ ? config.actions.outputDestinations
3474
+ : [];
3475
+ const currentAutoCreateIssue = Boolean(config?.actions?.autoCreateIssues ||
3476
+ configuredDestinations.includes('github_issue') ||
3477
+ (config?.deliveries?.github?.autoCreate && currentMode !== 'pull_request'));
3478
+ const currentAutoCreatePullRequest = Boolean(config?.actions?.autoCreatePullRequests ||
3479
+ configuredDestinations.includes('github_pull_request') ||
3480
+ (config?.deliveries?.github?.autoCreate && currentMode === 'pull_request'));
3481
+ const outputChoices = await askMultiChoice(rl, {
3482
+ title: 'Output destinations',
3483
+ subtitle: 'Use Up/Down to move, Space to toggle outputs, A to toggle all optional outputs, Enter to continue.',
3484
+ defaultValues: [
3485
+ 'chat',
3486
+ ...(currentAutoCreateIssue ? ['issue'] : []),
3487
+ ...(currentAutoCreatePullRequest ? ['pull_request'] : []),
3488
+ ],
3489
+ requiredValues: ['chat'],
3490
+ minSelections: 1,
3491
+ options: [
3492
+ {
3493
+ value: 'chat',
3494
+ label: 'OpenClaw chat',
3495
+ detail: 'Write readable summaries and leave GitHub as runtime fallback.',
3496
+ },
3497
+ {
3498
+ value: 'issue',
3499
+ label: 'GitHub issues',
3500
+ detail: 'Auto-create issues for concrete findings when GitHub access allows it.',
3501
+ },
3502
+ {
3503
+ value: 'pull_request',
3504
+ label: 'Draft PR proposals',
3505
+ detail: 'Auto-create draft PR-oriented proposal branches for implementation-ready fixes.',
3506
+ },
3507
+ ],
3508
+ });
3509
+ const wantsIssue = outputChoices.includes('issue');
3510
+ const wantsPullRequest = outputChoices.includes('pull_request');
3511
+ const summaryOnly = !wantsIssue && !wantsPullRequest;
3512
+ const mode = wantsPullRequest ? 'pull_request' : 'issue';
3513
+ const autoCreate = wantsIssue || wantsPullRequest;
3166
3514
  if (!summaryOnly) {
3167
- const detectedRepo = await detectGitHubRepo();
3168
- const currentRepo = config?.project?.githubRepo || detectedRepo || '';
3169
- config.project = {
3170
- ...(config.project || {}),
3171
- githubRepo: await ask(rl, 'GitHub repo for issue/PR delivery (owner/name)', currentRepo),
3172
- };
3515
+ 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');
3173
3516
  }
3174
3517
  const channels = await askNotificationChannels(rl, config);
3175
3518
  const connectorHealthChannels = channels.map((channel) => {
@@ -3184,8 +3527,15 @@ async function askOutputConfig(rl, config) {
3184
3527
  config.actions = {
3185
3528
  ...(config.actions || {}),
3186
3529
  mode,
3187
- autoCreateIssues: mode === 'issue' && autoCreate,
3188
- autoCreatePullRequests: mode === 'pull_request' && autoCreate,
3530
+ outputDestinations: [
3531
+ 'openclaw_chat',
3532
+ ...(wantsIssue ? ['github_issue'] : []),
3533
+ ...(wantsPullRequest ? ['github_pull_request'] : []),
3534
+ ],
3535
+ autoCreateIssues: wantsIssue,
3536
+ autoCreatePullRequests: wantsPullRequest,
3537
+ autoCreateWhenGitHubWriteAccess: config.actions?.autoCreateWhenGitHubWriteAccess !== false,
3538
+ disableAutoCreateGitHubArtifacts: config.actions?.disableAutoCreateGitHubArtifacts === true,
3189
3539
  draftPullRequests: true,
3190
3540
  proposalBranchPrefix: config?.actions?.proposalBranchPrefix || 'openclaw/proposals',
3191
3541
  };
@@ -3201,6 +3551,10 @@ async function askOutputConfig(rl, config) {
3201
3551
  ...(config.deliveries?.github || {}),
3202
3552
  enabled: !summaryOnly,
3203
3553
  mode,
3554
+ modes: [
3555
+ ...(wantsIssue ? ['issue'] : []),
3556
+ ...(wantsPullRequest ? ['pull_request'] : []),
3557
+ ],
3204
3558
  autoCreate,
3205
3559
  draftPullRequests: true,
3206
3560
  proposalBranchPrefix: config?.actions?.proposalBranchPrefix || 'openclaw/proposals',
@@ -3238,12 +3592,62 @@ async function askOutputConfig(rl, config) {
3238
3592
  };
3239
3593
  return config;
3240
3594
  }
3595
+ async function askGitHubArtifactDetails(rl, config) {
3596
+ const githubEnabled = Boolean(config?.actions?.autoCreateIssues ||
3597
+ config?.actions?.autoCreatePullRequests ||
3598
+ config?.deliveries?.github?.enabled ||
3599
+ config?.deliveries?.github?.autoCreate);
3600
+ config.project = {
3601
+ ...(config.project || {}),
3602
+ githubRepo: '',
3603
+ repoRoot: config.project?.repoRoot || '.',
3604
+ outFile: config.project?.outFile || 'data/openclaw-growth-engineer/issues.generated.json',
3605
+ maxIssues: Number(config.project?.maxIssues || 4),
3606
+ titlePrefix: config.project?.titlePrefix || '[Growth]',
3607
+ labels: Array.isArray(config.project?.labels) && config.project.labels.length > 0
3608
+ ? config.project.labels
3609
+ : ['ai-growth', 'autogenerated', 'product'],
3610
+ };
3611
+ if (!githubEnabled) {
3612
+ return config;
3613
+ }
3614
+ process.stdout.write('\nGitHub repo scope is not pinned by the wizard. OpenClaw/Hermes infers it from OPENCLAW_GITHUB_REPO, the local git remote, or runtime context.\n');
3615
+ const customize = await askYesNo(rl, 'Customize GitHub issue/PR limits, labels, or chart attachment settings?', false);
3616
+ if (!customize) {
3617
+ config.charting = {
3618
+ ...(config.charting || {}),
3619
+ enabled: config.charting?.enabled === true,
3620
+ command: config.charting?.command || null,
3621
+ };
3622
+ return config;
3623
+ }
3624
+ const labelsRaw = await ask(rl, 'GitHub labels for created issues/PRs', config.project.labels.join(','));
3625
+ config.project.labels = labelsRaw
3626
+ .split(',')
3627
+ .map((value) => value.trim())
3628
+ .filter(Boolean);
3629
+ config.project.maxIssues = Number.parseInt(await ask(rl, 'Maximum GitHub artifacts per run', String(config.project.maxIssues || 4)), 10) || 4;
3630
+ config.project.titlePrefix = await ask(rl, 'GitHub artifact title prefix', config.project.titlePrefix || '[Growth]');
3631
+ const enableCharting = await askYesNo(rl, 'Attach generated charts to GitHub artifacts when useful?', config.charting?.enabled === true);
3632
+ config.charting = {
3633
+ ...(config.charting || {}),
3634
+ enabled: enableCharting,
3635
+ command: enableCharting
3636
+ ? await ask(rl, 'Optional chart command override', config.charting?.command || '')
3637
+ : null,
3638
+ };
3639
+ return config;
3640
+ }
3241
3641
  async function askIntervalConfig(rl, config) {
3642
+ printSection('Schedule and analysis depth', [
3643
+ 'The runner wakes up often, but larger reviews only run on their daily/weekly/monthly cadence.',
3644
+ 'Connector health checks are separate and default to every 6 hours.',
3645
+ ]);
3242
3646
  const currentSchedule = config?.schedule || {};
3243
- const intervalMinutes = Number.parseInt(await ask(rl, 'Growth runner wake-up interval in minutes', String(currentSchedule.intervalMinutes || 1440)), 10) || 1440;
3244
- const connectorHealthCheckIntervalMinutes = Number.parseInt(await ask(rl, 'Connector health check interval in minutes', String(currentSchedule.connectorHealthCheckIntervalMinutes || 720)), 10) || 720;
3245
3647
  const usageMode = await askToolUsage(rl);
3246
- const cadences = await askCadencePlan(rl);
3648
+ 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;
3649
+ 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;
3650
+ const cadences = await askCadencePlan(rl, currentSchedule.cadences);
3247
3651
  config.schedule = {
3248
3652
  ...currentSchedule,
3249
3653
  intervalMinutes,
@@ -3258,11 +3662,31 @@ async function askIntervalConfig(rl, config) {
3258
3662
  };
3259
3663
  return config;
3260
3664
  }
3665
+ async function askOutputsAndIntervalsConfig(rl, config) {
3666
+ const withIntervals = await askIntervalConfig(rl, config);
3667
+ const withOutput = await askOutputConfig(rl, withIntervals);
3668
+ return await askGitHubArtifactDetails(rl, withOutput);
3669
+ }
3670
+ async function askInputSourceConfig(rl, config, configPath) {
3671
+ config = migrateRuntimeSourceCommands(config);
3672
+ await ensureDirForFile(configPath);
3673
+ await writeJsonFile(configPath, config);
3674
+ const healthByConnector = await withConnectorHealthLoading((onProgress) => getConnectorPickerHealth(configPath, onProgress));
3675
+ const selected = await askConnectorSelectionWithHealth(rl, healthByConnector, getInputChannelInitialSelection(config), {
3676
+ introTitle: 'Input channels',
3677
+ introDetail: null,
3678
+ actionTitle: 'Select input channels',
3679
+ helpText: 'Use Up/Down to move, Space to toggle channels, A to toggle all channels, Enter to continue.',
3680
+ mode: 'input',
3681
+ });
3682
+ config.sources = buildSourceConfigFromInputChannels(selected, config.sources || {});
3683
+ return { config, selected, healthByConnector };
3684
+ }
3261
3685
  async function writeOpenClawJobManifest(configPath, config) {
3262
3686
  const manifestPath = path.resolve('.openclaw/jobs/openclaw-growth-engineer.json');
3263
3687
  const displayConfigPath = path.relative(process.cwd(), configPath) || configPath;
3264
- const intervalMinutes = Math.max(1, Number(config?.schedule?.intervalMinutes || 1440));
3265
- const connectorHealthCheckIntervalMinutes = Math.max(1, Number(config?.schedule?.connectorHealthCheckIntervalMinutes || 720));
3688
+ const intervalMinutes = Math.max(1, Number(config?.schedule?.intervalMinutes || DEFAULT_GROWTH_INTERVAL_MINUTES));
3689
+ const connectorHealthCheckIntervalMinutes = Math.max(1, Number(config?.schedule?.connectorHealthCheckIntervalMinutes || DEFAULT_CONNECTOR_HEALTH_INTERVAL_MINUTES));
3266
3690
  const actionMode = config?.actions?.mode || config?.deliveries?.github?.mode || 'issue';
3267
3691
  const growthRunCommand = getGrowthRunCommand(config, displayConfigPath);
3268
3692
  const connectorHealthCommand = getConnectorHealthCommand(config, displayConfigPath);
@@ -3317,8 +3741,7 @@ async function main() {
3317
3741
  }
3318
3742
  const rl = createInterface({ input: process.stdin, output: process.stdout });
3319
3743
  try {
3320
- process.stdout.write('OpenClaw Growth Engineer - Setup Wizard\n');
3321
- process.stdout.write('This wizard writes non-secret config only. Connector secrets stay in the local secrets file.\n\n');
3744
+ printWizardHeader();
3322
3745
  const goal = await askWizardGoal(rl);
3323
3746
  if (goal === 'connectors') {
3324
3747
  rl.close();
@@ -3336,160 +3759,40 @@ async function main() {
3336
3759
  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');
3337
3760
  return;
3338
3761
  }
3339
- if (goal === 'output') {
3340
- const config = await askOutputConfig(rl, await loadEditableConfig(configPath));
3762
+ if (goal === 'outputs_intervals') {
3763
+ const config = await askOutputsAndIntervalsConfig(rl, await loadEditableConfig(configPath));
3341
3764
  const secretAccess = await askSecretAccessModel(rl, configPath, config);
3342
3765
  await writeJsonFile(configPath, config);
3343
3766
  const manifestPath = await writeOpenClawJobManifest(configPath, config);
3344
- process.stdout.write(`\nSaved output config: ${configPath}\n`);
3767
+ process.stdout.write(`\nSaved output and interval config: ${configPath}\n`);
3345
3768
  process.stdout.write(`Saved OpenClaw job manifest: ${manifestPath}\n`);
3346
3769
  printSecretRunnerKitInstructions(secretAccess.kit);
3347
- process.stdout.write('Connector-health alerts are deduped per unhealthy incident and sent through configured channels.\n');
3770
+ process.stdout.write('Daily checks prioritize Sentry and production anomalies; larger cadences analyze all configured projects and connectors.\n');
3348
3771
  return;
3349
3772
  }
3350
- const detectedRepo = await detectGitHubRepo();
3351
- const githubRepo = await ask(rl, 'GitHub repo (owner/name, optional; leave empty to infer later)', detectedRepo || '');
3352
- const labelsRaw = await ask(rl, 'Issue labels (comma-separated)', 'ai-growth,autogenerated,product');
3353
- const labels = labelsRaw
3354
- .split(',')
3355
- .map((value) => value.trim())
3356
- .filter(Boolean);
3357
- const maxIssues = Number.parseInt(await ask(rl, 'Max issues per run', '4'), 10) || 4;
3358
- const intervalMinutes = Number.parseInt(await ask(rl, 'Check interval in minutes', '1440'), 10) || 1440;
3359
- const usageMode = await askToolUsage(rl);
3360
- const cadences = await askCadencePlan(rl);
3361
- const actionMode = await askChoice(rl, 'Preferred GitHub artifact mode', ['issue', 'pull_request'], 'issue');
3362
- const analytics = await askSourceConfig(rl, 'analytics', 'data/openclaw-growth-engineer/analytics_summary.example.json', getDefaultSourceHint('analytics'), {
3363
- forceEnabled: true,
3364
- defaultCommand: getDefaultSourceCommand('analytics'),
3365
- });
3366
- const revenuecat = await askSourceConfig(rl, 'revenuecat', 'data/openclaw-growth-engineer/revenuecat_summary.example.json', getDefaultSourceHint('revenuecat'));
3367
- const sentry = await askSourceConfig(rl, 'sentry', 'data/openclaw-growth-engineer/sentry_summary.example.json', getDefaultSourceHint('sentry'));
3368
- const feedback = await askSourceConfig(rl, 'feedback', 'data/openclaw-growth-engineer/feedback_summary.example.json', getDefaultSourceHint('feedback'), {
3369
- defaultEnabled: true,
3370
- defaultCommand: getDefaultSourceCommand('feedback'),
3371
- cursorMode: 'auto_since_last_fetch',
3372
- initialLookback: '30d',
3373
- });
3374
- const extraSourcesRaw = await ask(rl, 'Extra connectors (comma-separated, e.g. firebase-crashlytics,app-store-reviews,play-console)', '');
3375
- const extraSources = extraSourcesRaw
3376
- .split(',')
3377
- .map((value) => value.trim())
3378
- .filter(Boolean)
3379
- .map((service) => {
3380
- const defaultCommand = getDefaultSourceCommand(service);
3381
- return buildExtraSourceConfig(service, defaultCommand ? {} : { mode: 'file', path: getDefaultSourcePath(service) });
3773
+ let config = await loadEditableConfig(configPath);
3774
+ config.version = Number(config.version || 7);
3775
+ config.generatedAt = new Date().toISOString();
3776
+ const inputSetup = await askInputSourceConfig(rl, config, configPath);
3777
+ config = inputSetup.config;
3778
+ await ensureDirForFile(configPath);
3779
+ await writeJsonFile(configPath, config);
3780
+ const connectorsOk = await runConnectorSetupSteps({
3781
+ rl,
3782
+ args: { ...args, config: configPath },
3783
+ selected: inputSetup.selected,
3784
+ healthByConnector: inputSetup.healthByConnector,
3785
+ allowIsolationPrompt: false,
3382
3786
  });
3383
- const autoCreateIssues = actionMode === 'issue'
3384
- ? await askYesNo(rl, 'Create GitHub issues automatically when new ideas are found?', false)
3385
- : false;
3386
- const autoCreatePullRequests = actionMode === 'pull_request'
3387
- ? await askYesNo(rl, 'Create draft pull requests with implementation proposal files automatically?', false)
3388
- : false;
3389
- const enableCharting = await askYesNo(rl, 'Generate matplotlib charts from analytics signals and include them in generated GitHub artifacts?', false);
3390
- const chartCommand = enableCharting
3391
- ? await ask(rl, 'Optional chart command override (leave empty for default python script)', '')
3392
- : '';
3393
- const config = {
3394
- version: 1,
3395
- generatedAt: new Date().toISOString(),
3396
- project: {
3397
- githubRepo,
3398
- repoRoot: '.',
3399
- outFile: 'data/openclaw-growth-engineer/issues.generated.json',
3400
- maxIssues,
3401
- titlePrefix: '[Growth]',
3402
- labels,
3403
- },
3404
- sources: {
3405
- analytics,
3406
- revenuecat,
3407
- sentry,
3408
- feedback,
3409
- extra: extraSources,
3410
- },
3411
- schedule: {
3412
- intervalMinutes,
3413
- connectorHealthCheckIntervalMinutes: 720,
3414
- skipIfNoDataChange: true,
3415
- skipIfIssueSetUnchanged: true,
3416
- cadences,
3417
- },
3418
- actions: {
3419
- autoCreateIssues,
3420
- autoCreatePullRequests,
3421
- mode: actionMode,
3422
- usageMode,
3423
- draftPullRequests: true,
3424
- proposalBranchPrefix: 'openclaw/proposals',
3425
- },
3426
- deliveries: {
3427
- openclawChat: {
3428
- enabled: true,
3429
- markdownPath: '.openclaw/chat/latest.md',
3430
- jsonPath: '.openclaw/chat/latest.json',
3431
- },
3432
- github: {
3433
- enabled: autoCreateIssues || autoCreatePullRequests,
3434
- mode: actionMode,
3435
- autoCreate: autoCreateIssues || autoCreatePullRequests,
3436
- draftPullRequests: true,
3437
- proposalBranchPrefix: 'openclaw/proposals',
3438
- },
3439
- slack: {
3440
- enabled: false,
3441
- webhookEnv: 'SLACK_WEBHOOK_URL',
3442
- },
3443
- webhook: {
3444
- enabled: false,
3445
- urlEnv: 'OPENCLAW_WEBHOOK_URL',
3446
- method: 'POST',
3447
- headers: {},
3448
- },
3449
- discord: {
3450
- enabled: false,
3451
- command: 'node scripts/discord-openclaw-bridge.mjs send --stdin',
3452
- },
3453
- },
3454
- charting: {
3455
- enabled: enableCharting,
3456
- command: chartCommand || null,
3457
- },
3458
- notifications: {
3459
- connectorHealth: {
3460
- enabled: true,
3461
- channels: [
3462
- {
3463
- type: 'openclaw-chat',
3464
- enabled: true,
3465
- markdownPath: '.openclaw/chat/connector-health.md',
3466
- jsonPath: '.openclaw/chat/connector-health.json',
3467
- },
3468
- ],
3469
- },
3470
- growthRun: {
3471
- enabled: true,
3472
- channels: [
3473
- {
3474
- type: 'openclaw-chat',
3475
- enabled: true,
3476
- markdownPath: '.openclaw/chat/growth-summary.md',
3477
- jsonPath: '.openclaw/chat/growth-summary.json',
3478
- },
3479
- ],
3480
- },
3481
- },
3482
- secrets: {
3483
- githubTokenEnv: 'GITHUB_TOKEN',
3484
- githubTokenRef: { source: 'env', provider: 'default', id: 'GITHUB_TOKEN' },
3485
- analyticsTokenEnv: 'ANALYTICSCLI_ACCESS_TOKEN',
3486
- analyticsTokenRef: { source: 'env', provider: 'default', id: 'ANALYTICSCLI_ACCESS_TOKEN' },
3487
- revenuecatTokenEnv: 'REVENUECAT_API_KEY',
3488
- revenuecatTokenRef: { source: 'env', provider: 'default', id: 'REVENUECAT_API_KEY' },
3489
- sentryTokenEnv: 'SENTRY_AUTH_TOKEN',
3490
- sentryTokenRef: { source: 'env', provider: 'default', id: 'SENTRY_AUTH_TOKEN' },
3491
- },
3492
- };
3787
+ if (!connectorsOk) {
3788
+ return;
3789
+ }
3790
+ config = await loadEditableConfig(configPath);
3791
+ config.version = Number(config.version || 7);
3792
+ config.generatedAt = new Date().toISOString();
3793
+ config = await askIntervalConfig(rl, config);
3794
+ config = await askOutputConfig(rl, config);
3795
+ config = await askGitHubArtifactDetails(rl, config);
3493
3796
  const secretAccess = await askSecretAccessModel(rl, configPath, config);
3494
3797
  await ensureDirForFile(configPath);
3495
3798
  await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
@@ -3499,14 +3802,8 @@ async function main() {
3499
3802
  printSecretRunnerKitInstructions(secretAccess.kit);
3500
3803
  process.stdout.write('\nNext steps:\n');
3501
3804
  process.stdout.write(`1) Set secrets in OpenClaw secret store (env var names in config.secrets)\n`);
3502
- if (extraSources.length > 0) {
3503
- process.stdout.write(`2) Fill each extra connector under \`sources.extra[]\` with the final file path or command and optional \`secretEnv\`\n`);
3504
- process.stdout.write(`3) Run once: node scripts/openclaw-growth-runner.mjs --config ${configPath}\n`);
3505
- process.stdout.write(`4) Run interval loop: node scripts/openclaw-growth-runner.mjs --config ${configPath} --loop\n`);
3506
- return;
3507
- }
3508
- process.stdout.write(`2) Run once: node scripts/openclaw-growth-runner.mjs --config ${configPath}\n`);
3509
- process.stdout.write(`3) Run interval loop: node scripts/openclaw-growth-runner.mjs --config ${configPath} --loop\n`);
3805
+ process.stdout.write(`2) Run once: ${growthEngineerPackageCommand(`run --config ${quote(configPath)}`)}\n`);
3806
+ process.stdout.write(`3) Run interval loop: ${growthEngineerPackageCommand(`run --config ${quote(configPath)} --loop`)}\n`);
3510
3807
  }
3511
3808
  finally {
3512
3809
  rl.close();
@@ -3514,6 +3811,6 @@ async function main() {
3514
3811
  }
3515
3812
  main().catch((error) => {
3516
3813
  process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
3517
- process.exitCode = 1;
3814
+ process.exitCode = error instanceof WizardAbortError ? error.exitCode : 1;
3518
3815
  });
3519
3816
  //# sourceMappingURL=openclaw-growth-wizard.mjs.map