@analyticscli/growth-engineer 0.1.0-preview.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +26 -0
- package/dist/config.d.ts +1663 -0
- package/dist/config.js +266 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1188 -0
- package/dist/index.js.map +1 -0
- package/dist/runtime/export-analytics-summary.d.mts +2 -0
- package/dist/runtime/export-analytics-summary.mjs +303 -0
- package/dist/runtime/export-analytics-summary.mjs.map +1 -0
- package/dist/runtime/export-asc-summary.d.mts +2 -0
- package/dist/runtime/export-asc-summary.mjs +376 -0
- package/dist/runtime/export-asc-summary.mjs.map +1 -0
- package/dist/runtime/export-revenuecat-summary.d.mts +2 -0
- package/dist/runtime/export-revenuecat-summary.mjs +176 -0
- package/dist/runtime/export-revenuecat-summary.mjs.map +1 -0
- package/dist/runtime/export-sentry-summary.d.mts +2 -0
- package/dist/runtime/export-sentry-summary.mjs +352 -0
- package/dist/runtime/export-sentry-summary.mjs.map +1 -0
- package/dist/runtime/openclaw-exporters-lib.d.mts +101 -0
- package/dist/runtime/openclaw-exporters-lib.mjs +1276 -0
- package/dist/runtime/openclaw-exporters-lib.mjs.map +1 -0
- package/dist/runtime/openclaw-feedback-api.d.mts +2 -0
- package/dist/runtime/openclaw-feedback-api.mjs +255 -0
- package/dist/runtime/openclaw-feedback-api.mjs.map +1 -0
- package/dist/runtime/openclaw-growth-charts.py +154 -0
- package/dist/runtime/openclaw-growth-engineer.d.mts +2 -0
- package/dist/runtime/openclaw-growth-engineer.mjs +1258 -0
- package/dist/runtime/openclaw-growth-engineer.mjs.map +1 -0
- package/dist/runtime/openclaw-growth-env.d.mts +9 -0
- package/dist/runtime/openclaw-growth-env.mjs +125 -0
- package/dist/runtime/openclaw-growth-env.mjs.map +1 -0
- package/dist/runtime/openclaw-growth-preflight.d.mts +2 -0
- package/dist/runtime/openclaw-growth-preflight.mjs +1111 -0
- package/dist/runtime/openclaw-growth-preflight.mjs.map +1 -0
- package/dist/runtime/openclaw-growth-runner.d.mts +2 -0
- package/dist/runtime/openclaw-growth-runner.mjs +1302 -0
- package/dist/runtime/openclaw-growth-runner.mjs.map +1 -0
- package/dist/runtime/openclaw-growth-shared.d.mts +33 -0
- package/dist/runtime/openclaw-growth-shared.mjs +208 -0
- package/dist/runtime/openclaw-growth-shared.mjs.map +1 -0
- package/dist/runtime/openclaw-growth-start.d.mts +2 -0
- package/dist/runtime/openclaw-growth-start.mjs +1575 -0
- package/dist/runtime/openclaw-growth-start.mjs.map +1 -0
- package/dist/runtime/openclaw-growth-status.d.mts +2 -0
- package/dist/runtime/openclaw-growth-status.mjs +387 -0
- package/dist/runtime/openclaw-growth-status.mjs.map +1 -0
- package/dist/runtime/openclaw-growth-wizard.d.mts +2 -0
- package/dist/runtime/openclaw-growth-wizard.mjs +3519 -0
- package/dist/runtime/openclaw-growth-wizard.mjs.map +1 -0
- package/dist/shell.d.ts +17 -0
- package/dist/shell.js +40 -0
- package/dist/shell.js.map +1 -0
- package/package.json +38 -0
- package/templates/analytics_summary.example.json +40 -0
- package/templates/config.example.json +197 -0
- package/templates/feedback_summary.example.json +37 -0
- package/templates/revenuecat_summary.example.json +25 -0
- package/templates/sentry_summary.example.json +23 -0
|
@@ -0,0 +1,3519 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { createInterface } from 'node:readline/promises';
|
|
7
|
+
import { emitKeypressEvents } from 'node:readline';
|
|
8
|
+
import { createPrivateKey } from 'node:crypto';
|
|
9
|
+
import { buildExtraSourceConfig, getDefaultSourceCommand, getDefaultSourceHint, getDefaultSourcePath, } from './openclaw-growth-shared.mjs';
|
|
10
|
+
import { loadOpenClawGrowthSecrets } from './openclaw-growth-env.mjs';
|
|
11
|
+
const DEFAULT_CONFIG_PATH = 'data/openclaw-growth-engineer/config.json';
|
|
12
|
+
const SELF_UPDATE_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
13
|
+
const ENABLE_ISOLATED_SECRET_RUNNER_WIZARD = false;
|
|
14
|
+
const CONNECTOR_KEYS = ['analytics', 'github', 'revenuecat', 'sentry', 'asc'];
|
|
15
|
+
const CONNECTOR_DEFINITIONS = [
|
|
16
|
+
{
|
|
17
|
+
key: 'analytics',
|
|
18
|
+
label: 'AnalyticsCLI product analytics',
|
|
19
|
+
summary: 'Read product events, funnels, retention, users, and feedback.',
|
|
20
|
+
needs: 'An AnalyticsCLI readonly token from dash.analyticscli.com.',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
key: 'github',
|
|
24
|
+
label: 'GitHub code access',
|
|
25
|
+
summary: 'Read repo context and optionally create issues or draft PRs.',
|
|
26
|
+
needs: 'Create a GitHub token with the scopes you want; you can change it later by rerunning the wizard.',
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
key: 'revenuecat',
|
|
30
|
+
label: 'RevenueCat monetization data',
|
|
31
|
+
summary: 'Read subscription, product, entitlement, and revenue context.',
|
|
32
|
+
needs: 'A RevenueCat v2 secret API key with read-only project permissions.',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
key: 'sentry',
|
|
36
|
+
label: 'Sentry-compatible crash monitoring',
|
|
37
|
+
summary: 'Read unresolved crashes, regressions, affected users, releases, and production stability signals.',
|
|
38
|
+
needs: 'A Sentry or GlitchTip-compatible auth token plus the org slug. Project scope is inferred later from app context or config.',
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
key: 'asc',
|
|
42
|
+
label: 'ASC / App Store Connect CLI',
|
|
43
|
+
summary: 'Read App Store analytics, reviews/ratings, builds/TestFlight/release context, subscriptions, purchases, and crash totals.',
|
|
44
|
+
needs: 'ASC_KEY_ID, ASC_ISSUER_ID, and the AuthKey_XXXX.p8 content or path.',
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
const DEFAULT_CADENCE_PLAN = [
|
|
48
|
+
{
|
|
49
|
+
key: 'daily',
|
|
50
|
+
title: 'Daily production guardrail',
|
|
51
|
+
intervalDays: 1,
|
|
52
|
+
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.',
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
key: 'weekly',
|
|
60
|
+
title: 'Weekly conversion, traffic, and RevenueCat review',
|
|
61
|
+
intervalDays: 7,
|
|
62
|
+
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.',
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
key: 'monthly',
|
|
70
|
+
title: 'Monthly business and product review',
|
|
71
|
+
intervalDays: 30,
|
|
72
|
+
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.',
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
key: 'quarterly',
|
|
80
|
+
title: 'Quarterly positioning, pricing, and roadmap review',
|
|
81
|
+
intervalDays: 91,
|
|
82
|
+
criticalOnly: false,
|
|
83
|
+
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.',
|
|
86
|
+
instructions: 'Find structural constraints and durable opportunities. Tie recommendations to cohort behavior, monetization, reviews, channel quality, and shipped changes.',
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
key: 'six_months',
|
|
90
|
+
title: 'Six-month instrumentation and growth-system audit',
|
|
91
|
+
intervalDays: 182,
|
|
92
|
+
criticalOnly: false,
|
|
93
|
+
focusAreas: ['retention', 'conversion', 'paywall', 'marketing', 'general'],
|
|
94
|
+
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.',
|
|
96
|
+
instructions: 'Prioritize measurement fixes and system changes that make future analysis more trustworthy. Identify stale events, missing attribution, weak identity, and misleading dashboards.',
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
key: 'yearly',
|
|
100
|
+
title: 'Yearly evidence reset',
|
|
101
|
+
intervalDays: 365,
|
|
102
|
+
criticalOnly: false,
|
|
103
|
+
focusAreas: ['marketing', 'retention', 'paywall', 'conversion', 'general'],
|
|
104
|
+
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.',
|
|
106
|
+
instructions: 'Use the full year of memory, releases, revenue, acquisition, reviews, code changes, and cohort behavior. Produce strategic experiments and stop-doing decisions.',
|
|
107
|
+
},
|
|
108
|
+
];
|
|
109
|
+
const ANSI = {
|
|
110
|
+
bold: '\x1b[1m',
|
|
111
|
+
cyan: '\x1b[36m',
|
|
112
|
+
dim: '\x1b[2m',
|
|
113
|
+
green: '\x1b[32m',
|
|
114
|
+
hideCursor: '\x1b[?25l',
|
|
115
|
+
reset: '\x1b[0m',
|
|
116
|
+
showCursor: '\x1b[?25h',
|
|
117
|
+
};
|
|
118
|
+
async function ensureDirForFile(filePath) {
|
|
119
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
120
|
+
}
|
|
121
|
+
async function fileExists(filePath) {
|
|
122
|
+
try {
|
|
123
|
+
await fs.access(filePath);
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
async function readJsonFile(filePath) {
|
|
131
|
+
return JSON.parse(await fs.readFile(filePath, 'utf8'));
|
|
132
|
+
}
|
|
133
|
+
async function readJsonIfPresent(filePath) {
|
|
134
|
+
if (!(await fileExists(filePath)))
|
|
135
|
+
return null;
|
|
136
|
+
return readJsonFile(filePath);
|
|
137
|
+
}
|
|
138
|
+
async function writeJsonFile(filePath, value) {
|
|
139
|
+
await ensureDirForFile(filePath);
|
|
140
|
+
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
141
|
+
}
|
|
142
|
+
function isTruthyEnv(value) {
|
|
143
|
+
return ['1', 'true', 'yes', 'y', 'on'].includes(String(value || '').trim().toLowerCase());
|
|
144
|
+
}
|
|
145
|
+
function isFalseyEnv(value) {
|
|
146
|
+
return ['0', 'false', 'no', 'n', 'off'].includes(String(value || '').trim().toLowerCase());
|
|
147
|
+
}
|
|
148
|
+
function parseArgs(argv) {
|
|
149
|
+
const args = {
|
|
150
|
+
config: DEFAULT_CONFIG_PATH,
|
|
151
|
+
connectorWizard: false,
|
|
152
|
+
connectors: '',
|
|
153
|
+
noSelfUpdate: false,
|
|
154
|
+
out: DEFAULT_CONFIG_PATH,
|
|
155
|
+
};
|
|
156
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
157
|
+
const token = argv[i];
|
|
158
|
+
const next = argv[i + 1];
|
|
159
|
+
if (token === '--') {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
else if (token === '--config') {
|
|
163
|
+
args.config = next || args.config;
|
|
164
|
+
args.out = next || args.out;
|
|
165
|
+
i += 1;
|
|
166
|
+
}
|
|
167
|
+
else if (token === '--connectors' || token === '--connector-setup') {
|
|
168
|
+
args.connectorWizard = true;
|
|
169
|
+
if (next && !next.startsWith('-')) {
|
|
170
|
+
args.connectors = next;
|
|
171
|
+
i += 1;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
else if (token === '--out') {
|
|
175
|
+
args.out = next;
|
|
176
|
+
args.config = next;
|
|
177
|
+
i += 1;
|
|
178
|
+
}
|
|
179
|
+
else if (token === '--no-self-update') {
|
|
180
|
+
args.noSelfUpdate = true;
|
|
181
|
+
}
|
|
182
|
+
else if (token === '--help' || token === '-h') {
|
|
183
|
+
printHelpAndExit(0);
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
printHelpAndExit(1, `Unknown argument: ${token}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return args;
|
|
190
|
+
}
|
|
191
|
+
function printHelpAndExit(exitCode, reason = null) {
|
|
192
|
+
if (reason) {
|
|
193
|
+
process.stderr.write(`${reason}\n\n`);
|
|
194
|
+
}
|
|
195
|
+
process.stdout.write(`
|
|
196
|
+
OpenClaw Growth Setup Wizard
|
|
197
|
+
|
|
198
|
+
Usage:
|
|
199
|
+
node scripts/openclaw-growth-wizard.mjs [--out <config-path>]
|
|
200
|
+
node scripts/openclaw-growth-wizard.mjs --connectors [analytics,github,revenuecat,sentry,asc] [--config <config-path>]
|
|
201
|
+
|
|
202
|
+
Options:
|
|
203
|
+
--no-self-update Skip the ClawHub skill update check for this run
|
|
204
|
+
`);
|
|
205
|
+
process.exit(exitCode);
|
|
206
|
+
}
|
|
207
|
+
function quote(value) {
|
|
208
|
+
if (/^[a-zA-Z0-9_./:-]+$/.test(String(value))) {
|
|
209
|
+
return String(value);
|
|
210
|
+
}
|
|
211
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
212
|
+
}
|
|
213
|
+
function normalizeConnectorKey(value) {
|
|
214
|
+
const normalized = String(value || '').trim().toLowerCase().replace(/[_\s]+/g, '-');
|
|
215
|
+
if (!normalized)
|
|
216
|
+
return null;
|
|
217
|
+
if (normalized === 'all')
|
|
218
|
+
return 'all';
|
|
219
|
+
if (['analytics', 'analyticscli', 'product-analytics', 'events'].includes(normalized))
|
|
220
|
+
return 'analytics';
|
|
221
|
+
if (['github', 'gh', 'github-code', 'codebase', 'code-access'].includes(normalized))
|
|
222
|
+
return 'github';
|
|
223
|
+
if (['revenuecat', 'revenue-cat', 'rc', 'revenuecat-mcp'].includes(normalized))
|
|
224
|
+
return 'revenuecat';
|
|
225
|
+
if (['sentry', 'sentry-api', 'sentry-mcp', 'crashes', 'errors', 'crash-reporting'].includes(normalized))
|
|
226
|
+
return 'sentry';
|
|
227
|
+
if (['asc', 'asc-cli', 'app-store-connect', 'appstoreconnect', 'app-store'].includes(normalized))
|
|
228
|
+
return 'asc';
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
function parseConnectorList(value) {
|
|
232
|
+
const selected = new Set();
|
|
233
|
+
for (const entry of String(value || '').split(',')) {
|
|
234
|
+
const connector = normalizeConnectorKey(entry);
|
|
235
|
+
if (!connector)
|
|
236
|
+
continue;
|
|
237
|
+
if (connector === 'all') {
|
|
238
|
+
CONNECTOR_KEYS.forEach((key) => selected.add(key));
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
selected.add(connector);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return [...selected];
|
|
245
|
+
}
|
|
246
|
+
function isConnectorLocallyConfigured(key) {
|
|
247
|
+
if (key === 'analytics') {
|
|
248
|
+
return Boolean(process.env.ANALYTICSCLI_ACCESS_TOKEN?.trim() || process.env.ANALYTICSCLI_READONLY_TOKEN?.trim());
|
|
249
|
+
}
|
|
250
|
+
if (key === 'github')
|
|
251
|
+
return Boolean(process.env.GITHUB_TOKEN?.trim());
|
|
252
|
+
if (key === 'revenuecat')
|
|
253
|
+
return Boolean(process.env.REVENUECAT_API_KEY?.trim());
|
|
254
|
+
if (key === 'sentry')
|
|
255
|
+
return Boolean(process.env.SENTRY_AUTH_TOKEN?.trim());
|
|
256
|
+
if (key === 'asc') {
|
|
257
|
+
return Boolean(process.env.ASC_KEY_ID?.trim() &&
|
|
258
|
+
process.env.ASC_ISSUER_ID?.trim() &&
|
|
259
|
+
(process.env.ASC_PRIVATE_KEY_PATH?.trim() || process.env.ASC_PRIVATE_KEY?.trim()));
|
|
260
|
+
}
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
function getRequiredConnectorKeys() {
|
|
264
|
+
return new Set(isConnectorLocallyConfigured('analytics') ? [] : ['analytics']);
|
|
265
|
+
}
|
|
266
|
+
function withMissingRequiredAnalyticsConnector(selected) {
|
|
267
|
+
if (isConnectorLocallyConfigured('analytics') || selected.includes('analytics'))
|
|
268
|
+
return orderConnectors(selected);
|
|
269
|
+
return orderConnectors(['analytics', ...selected]);
|
|
270
|
+
}
|
|
271
|
+
async function askConnectorSelection(rl) {
|
|
272
|
+
return askConnectorSelectionWithHealth(rl, {}, []);
|
|
273
|
+
}
|
|
274
|
+
async function askConnectorSelectionWithHealth(rl, healthByConnector = {}, initialSelected = []) {
|
|
275
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY || !process.stdin.setRawMode) {
|
|
276
|
+
return await askConnectorSelectionByText(rl, healthByConnector);
|
|
277
|
+
}
|
|
278
|
+
rl.pause();
|
|
279
|
+
try {
|
|
280
|
+
return await askConnectorSelectionByKeys(healthByConnector, initialSelected);
|
|
281
|
+
}
|
|
282
|
+
finally {
|
|
283
|
+
rl.resume();
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
async function askConnectorSelectionByText(rl, healthByConnector = {}) {
|
|
287
|
+
printConnectorIntro();
|
|
288
|
+
for (const group of connectorPickerGroups(healthByConnector)) {
|
|
289
|
+
process.stdout.write(`${ANSI.bold}${group.title}${ANSI.reset}\n`);
|
|
290
|
+
for (const connector of group.connectors) {
|
|
291
|
+
const number = CONNECTOR_DEFINITIONS.findIndex((entry) => entry.key === connector.key) + 1;
|
|
292
|
+
process.stdout.write(` ${number}) ${connector.label}\n`);
|
|
293
|
+
writeWrapped(formatConnectorHealthText(connector.key, healthByConnector), ' ', ANSI.dim);
|
|
294
|
+
writeWrapped(connector.summary, ' ');
|
|
295
|
+
}
|
|
296
|
+
process.stdout.write('\n');
|
|
297
|
+
}
|
|
298
|
+
while (true) {
|
|
299
|
+
const answer = await ask(rl, 'Select connectors (comma-separated numbers/names, or all)', 'all');
|
|
300
|
+
const selected = parseConnectorAnswer(answer);
|
|
301
|
+
if (selected.length > 0)
|
|
302
|
+
return selected;
|
|
303
|
+
process.stdout.write('\nChoose at least one connector.\n\n');
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
function parseConnectorAnswer(answer) {
|
|
307
|
+
const selected = new Set();
|
|
308
|
+
for (const rawEntry of String(answer || '').split(',')) {
|
|
309
|
+
const entry = rawEntry.trim().toLowerCase();
|
|
310
|
+
const numericConnector = CONNECTOR_DEFINITIONS[Number(entry) - 1]?.key;
|
|
311
|
+
if (numericConnector)
|
|
312
|
+
selected.add(numericConnector);
|
|
313
|
+
const key = normalizeConnectorKey(entry);
|
|
314
|
+
if (key === 'all')
|
|
315
|
+
CONNECTOR_KEYS.forEach((connector) => selected.add(connector));
|
|
316
|
+
if (key && key !== 'all')
|
|
317
|
+
selected.add(key);
|
|
318
|
+
}
|
|
319
|
+
return orderConnectors([...selected]);
|
|
320
|
+
}
|
|
321
|
+
function orderConnectors(keys) {
|
|
322
|
+
const selected = new Set(keys);
|
|
323
|
+
return CONNECTOR_KEYS.filter((key) => selected.has(key));
|
|
324
|
+
}
|
|
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`);
|
|
328
|
+
}
|
|
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);
|
|
337
|
+
try {
|
|
338
|
+
return await task;
|
|
339
|
+
}
|
|
340
|
+
finally {
|
|
341
|
+
clearInterval(timer);
|
|
342
|
+
process.stdout.write(`\r${message} done\n`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
function normalizeConnectorProgressKey(key) {
|
|
346
|
+
const normalized = String(key || '').trim().toLowerCase();
|
|
347
|
+
if (normalized === 'analytics' || normalized === 'analyticscli')
|
|
348
|
+
return 'analytics';
|
|
349
|
+
if (normalized === 'github')
|
|
350
|
+
return 'github';
|
|
351
|
+
if (normalized === 'revenuecat')
|
|
352
|
+
return 'revenuecat';
|
|
353
|
+
if (normalized === 'sentry')
|
|
354
|
+
return 'sentry';
|
|
355
|
+
if (normalized === 'asc' || normalized === 'appstoreconnect' || normalized === 'app-store-connect')
|
|
356
|
+
return 'asc';
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
async function withConnectorHealthLoading(taskFactory) {
|
|
360
|
+
const frames = ['-', '\\', '|', '/'];
|
|
361
|
+
const completed = new Set();
|
|
362
|
+
let index = 0;
|
|
363
|
+
let current = 'starting';
|
|
364
|
+
const render = () => {
|
|
365
|
+
const count = Math.min(completed.size, CONNECTOR_KEYS.length);
|
|
366
|
+
process.stdout.write(`\rChecking connector health ${count}/${CONNECTOR_KEYS.length} (${current}) ${frames[index]}`);
|
|
367
|
+
};
|
|
368
|
+
const timer = setInterval(() => {
|
|
369
|
+
index = (index + 1) % frames.length;
|
|
370
|
+
render();
|
|
371
|
+
}, 120);
|
|
372
|
+
render();
|
|
373
|
+
try {
|
|
374
|
+
const result = await taskFactory((event) => {
|
|
375
|
+
const key = normalizeConnectorProgressKey(event?.key);
|
|
376
|
+
if (!key)
|
|
377
|
+
return;
|
|
378
|
+
current = connectorLabel(key);
|
|
379
|
+
if (event?.phase === 'finish')
|
|
380
|
+
completed.add(key);
|
|
381
|
+
render();
|
|
382
|
+
});
|
|
383
|
+
CONNECTOR_KEYS.forEach((key) => completed.add(key));
|
|
384
|
+
current = 'done';
|
|
385
|
+
render();
|
|
386
|
+
process.stdout.write('\n');
|
|
387
|
+
return result;
|
|
388
|
+
}
|
|
389
|
+
finally {
|
|
390
|
+
clearInterval(timer);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
function connectorLabel(key) {
|
|
394
|
+
return CONNECTOR_DEFINITIONS.find((connector) => connector.key === key)?.label ?? key;
|
|
395
|
+
}
|
|
396
|
+
function toConfigId(value, fallback) {
|
|
397
|
+
return String(value || fallback)
|
|
398
|
+
.trim()
|
|
399
|
+
.toLowerCase()
|
|
400
|
+
.replace(/https?:\/\//g, '')
|
|
401
|
+
.replace(/[^a-zA-Z0-9._-]+/g, '_')
|
|
402
|
+
.replace(/^_+|_+$/g, '') || fallback;
|
|
403
|
+
}
|
|
404
|
+
function toEnvName(value, fallback) {
|
|
405
|
+
return String(value || fallback)
|
|
406
|
+
.trim()
|
|
407
|
+
.toUpperCase()
|
|
408
|
+
.replace(/[^A-Z0-9]+/g, '_')
|
|
409
|
+
.replace(/^_+|_+$/g, '') || fallback;
|
|
410
|
+
}
|
|
411
|
+
function connectorHealthLabel(status) {
|
|
412
|
+
if (status === 'connected')
|
|
413
|
+
return 'healthy';
|
|
414
|
+
if (status === 'partial')
|
|
415
|
+
return 'partial';
|
|
416
|
+
if (status === 'blocked')
|
|
417
|
+
return 'blocked';
|
|
418
|
+
if (status === 'not_enabled')
|
|
419
|
+
return 'not enabled';
|
|
420
|
+
if (status === 'not_connected')
|
|
421
|
+
return 'not connected';
|
|
422
|
+
if (status === 'unknown')
|
|
423
|
+
return 'unknown';
|
|
424
|
+
return status || 'not checked';
|
|
425
|
+
}
|
|
426
|
+
function getConnectorHealth(key, healthByConnector = {}) {
|
|
427
|
+
const fallbackStatus = isConnectorLocallyConfigured(key) ? 'unknown' : 'not_connected';
|
|
428
|
+
const fallbackDetail = isConnectorLocallyConfigured(key)
|
|
429
|
+
? 'credentials exist, but live health was not verified'
|
|
430
|
+
: '';
|
|
431
|
+
return healthByConnector[key] || { status: fallbackStatus, detail: fallbackDetail };
|
|
432
|
+
}
|
|
433
|
+
function connectorStatusLabel(key, healthByConnector = {}) {
|
|
434
|
+
const health = getConnectorHealth(key, healthByConnector);
|
|
435
|
+
const configured = isConnectorLocallyConfigured(key);
|
|
436
|
+
if (health.status === 'connected')
|
|
437
|
+
return configured ? 'configured, healthy' : 'healthy via local tool auth';
|
|
438
|
+
if (!configured)
|
|
439
|
+
return 'not configured';
|
|
440
|
+
return `configured, ${connectorHealthLabel(health.status)}`;
|
|
441
|
+
}
|
|
442
|
+
function formatConnectorHealthLine(key, healthByConnector = {}) {
|
|
443
|
+
return `${ANSI.dim}${formatConnectorHealthText(key, healthByConnector)}${ANSI.reset}`;
|
|
444
|
+
}
|
|
445
|
+
function formatConnectorHealthText(key, healthByConnector = {}) {
|
|
446
|
+
const health = getConnectorHealth(key, healthByConnector);
|
|
447
|
+
const label = connectorStatusLabel(key, healthByConnector);
|
|
448
|
+
const detail = health.detail ? ` - ${health.detail}` : '';
|
|
449
|
+
return `Status: ${label}${detail}`;
|
|
450
|
+
}
|
|
451
|
+
function wrapText(text, indent = '', width = process.stdout.columns || 100) {
|
|
452
|
+
const available = Math.max(32, width - indent.length);
|
|
453
|
+
const words = String(text || '').split(/\s+/).filter(Boolean);
|
|
454
|
+
const lines = [];
|
|
455
|
+
let current = '';
|
|
456
|
+
for (const word of words) {
|
|
457
|
+
if (!current) {
|
|
458
|
+
current = word;
|
|
459
|
+
}
|
|
460
|
+
else if (`${current} ${word}`.length <= available) {
|
|
461
|
+
current = `${current} ${word}`;
|
|
462
|
+
}
|
|
463
|
+
else {
|
|
464
|
+
lines.push(current);
|
|
465
|
+
current = word;
|
|
466
|
+
}
|
|
467
|
+
while (current.length > available) {
|
|
468
|
+
lines.push(current.slice(0, available));
|
|
469
|
+
current = current.slice(available);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
if (current)
|
|
473
|
+
lines.push(current);
|
|
474
|
+
return lines.length > 0 ? lines.map((line) => `${indent}${line}`) : [indent.trimEnd()];
|
|
475
|
+
}
|
|
476
|
+
function writeWrapped(text, indent = '', style = '') {
|
|
477
|
+
for (const line of wrapText(text, indent)) {
|
|
478
|
+
process.stdout.write(style ? `${style}${line}${ANSI.reset}\n` : `${line}\n`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
function connectorPickerGroups(healthByConnector = {}) {
|
|
482
|
+
const groups = [
|
|
483
|
+
{ title: 'Configured - needs attention', connectors: [] },
|
|
484
|
+
{ title: 'Configured - healthy', connectors: [] },
|
|
485
|
+
{ title: 'Not configured', connectors: [] },
|
|
486
|
+
];
|
|
487
|
+
for (const connector of CONNECTOR_DEFINITIONS) {
|
|
488
|
+
const configured = isConnectorLocallyConfigured(connector.key);
|
|
489
|
+
const health = getConnectorHealth(connector.key, healthByConnector);
|
|
490
|
+
if (!configured && health.status !== 'connected') {
|
|
491
|
+
groups[2].connectors.push(connector);
|
|
492
|
+
}
|
|
493
|
+
else if (health.status === 'connected') {
|
|
494
|
+
groups[1].connectors.push(connector);
|
|
495
|
+
}
|
|
496
|
+
else {
|
|
497
|
+
groups[0].connectors.push(connector);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return groups.filter((group) => group.connectors.length > 0);
|
|
501
|
+
}
|
|
502
|
+
function connectorPickerDisplayItems(healthByConnector = {}) {
|
|
503
|
+
return connectorPickerGroups(healthByConnector).flatMap((group) => group.connectors);
|
|
504
|
+
}
|
|
505
|
+
function connectorKeysNeedingAttention(healthByConnector = {}) {
|
|
506
|
+
return CONNECTOR_KEYS.filter((key) => ['blocked', 'partial', 'unknown', 'not_connected'].includes(String(getConnectorHealth(key, healthByConnector).status || '')));
|
|
507
|
+
}
|
|
508
|
+
async function getConnectorPickerHealth(configPath, onProgress = () => { }) {
|
|
509
|
+
if (!(await fileExists(configPath))) {
|
|
510
|
+
return Object.fromEntries(CONNECTOR_KEYS.map((key) => [
|
|
511
|
+
key,
|
|
512
|
+
{
|
|
513
|
+
status: isConnectorLocallyConfigured(key) ? 'unknown' : 'not_connected',
|
|
514
|
+
detail: isConnectorLocallyConfigured(key)
|
|
515
|
+
? `config file not found at ${configPath}; live check could not run`
|
|
516
|
+
: '',
|
|
517
|
+
},
|
|
518
|
+
]));
|
|
519
|
+
}
|
|
520
|
+
const result = await runCommandCaptureWithProgress(`node scripts/openclaw-growth-status.mjs --config ${quote(configPath)} --json --progress-json`, onProgress);
|
|
521
|
+
const payload = parseJsonFromStdout(result.stdout);
|
|
522
|
+
const connectors = payload?.connectors && typeof payload.connectors === 'object' ? payload.connectors : {};
|
|
523
|
+
const healthByConnector = {
|
|
524
|
+
analytics: connectors.analyticscli,
|
|
525
|
+
github: connectors.github,
|
|
526
|
+
revenuecat: connectors.revenuecat,
|
|
527
|
+
sentry: connectors.sentry,
|
|
528
|
+
asc: connectors.appStoreConnect,
|
|
529
|
+
};
|
|
530
|
+
return Object.fromEntries(CONNECTOR_KEYS.map((key) => [key, getConnectorHealth(key, healthByConnector)]));
|
|
531
|
+
}
|
|
532
|
+
function renderConnectorPicker(cursorIndex, selected, required, healthByConnector = {}, warning = '') {
|
|
533
|
+
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);
|
|
537
|
+
process.stdout.write('\n');
|
|
538
|
+
let index = 0;
|
|
539
|
+
for (const group of connectorPickerGroups(healthByConnector)) {
|
|
540
|
+
process.stdout.write(`${ANSI.bold}${group.title}${ANSI.reset}\n`);
|
|
541
|
+
for (const connector of group.connectors) {
|
|
542
|
+
const active = index === cursorIndex;
|
|
543
|
+
const isRequired = required.has(connector.key);
|
|
544
|
+
const checked = isRequired || selected.has(connector.key);
|
|
545
|
+
const pointer = active ? `${ANSI.cyan}>${ANSI.reset}` : ' ';
|
|
546
|
+
const box = checked ? `${ANSI.green}[x]${ANSI.reset}` : '[ ]';
|
|
547
|
+
const suffix = isRequired ? ' (required baseline)' : '';
|
|
548
|
+
const label = `${connector.label}${suffix}`;
|
|
549
|
+
const title = active ? `${ANSI.bold}${label}${ANSI.reset}` : label;
|
|
550
|
+
process.stdout.write(`${pointer} ${box} ${title}\n`);
|
|
551
|
+
writeWrapped(connector.summary, ' ');
|
|
552
|
+
writeWrapped(formatConnectorHealthText(connector.key, healthByConnector), ' ', ANSI.dim);
|
|
553
|
+
process.stdout.write('\n');
|
|
554
|
+
index += 1;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
if (warning) {
|
|
558
|
+
process.stdout.write(`${ANSI.bold}${warning}${ANSI.reset}\n\n`);
|
|
559
|
+
}
|
|
560
|
+
process.stdout.write(`${ANSI.dim}Esc/Q cancels. Number keys 1-${CONNECTOR_DEFINITIONS.length} also toggle connectors.${ANSI.reset}\n`);
|
|
561
|
+
}
|
|
562
|
+
async function askConnectorSelectionByKeys(healthByConnector = {}, initialSelected = []) {
|
|
563
|
+
emitKeypressEvents(process.stdin);
|
|
564
|
+
const wasRaw = process.stdin.isRaw;
|
|
565
|
+
const wasPaused = process.stdin.isPaused();
|
|
566
|
+
process.stdin.setRawMode(true);
|
|
567
|
+
process.stdin.resume();
|
|
568
|
+
let cursorIndex = 0;
|
|
569
|
+
const required = getRequiredConnectorKeys();
|
|
570
|
+
const initial = new Set(initialSelected);
|
|
571
|
+
const selected = new Set(CONNECTOR_KEYS.filter((key) => required.has(key) || initial.has(key) || !isConnectorLocallyConfigured(key)));
|
|
572
|
+
let warning = '';
|
|
573
|
+
return await new Promise((resolve, reject) => {
|
|
574
|
+
const displayItems = () => connectorPickerDisplayItems(healthByConnector);
|
|
575
|
+
const selectedDisplayConnector = () => displayItems()[cursorIndex] || displayItems()[0];
|
|
576
|
+
const displayIndexForConnector = (key) => Math.max(0, displayItems().findIndex((connector) => connector.key === key));
|
|
577
|
+
const cleanup = () => {
|
|
578
|
+
process.stdin.off('keypress', onKeypress);
|
|
579
|
+
process.stdin.setRawMode(Boolean(wasRaw));
|
|
580
|
+
if (wasPaused) {
|
|
581
|
+
process.stdin.pause();
|
|
582
|
+
}
|
|
583
|
+
process.stdout.write(ANSI.showCursor);
|
|
584
|
+
};
|
|
585
|
+
const finish = () => {
|
|
586
|
+
required.forEach((key) => selected.add(key));
|
|
587
|
+
if (selected.size === 0) {
|
|
588
|
+
warning = 'No connectors selected. Select a connector to update or press Esc to cancel.';
|
|
589
|
+
renderConnectorPicker(cursorIndex, selected, required, healthByConnector, warning);
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
cleanup();
|
|
593
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
594
|
+
resolve(orderConnectors([...selected]));
|
|
595
|
+
};
|
|
596
|
+
const cancel = () => {
|
|
597
|
+
cleanup();
|
|
598
|
+
process.stdout.write('\n');
|
|
599
|
+
reject(new Error('Connector setup cancelled.'));
|
|
600
|
+
};
|
|
601
|
+
const toggleCurrent = () => {
|
|
602
|
+
const connector = selectedDisplayConnector();
|
|
603
|
+
if (!connector)
|
|
604
|
+
return;
|
|
605
|
+
const key = connector.key;
|
|
606
|
+
if (required.has(key)) {
|
|
607
|
+
selected.add(key);
|
|
608
|
+
warning = 'AnalyticsCLI is missing and required for the Growth Engineer baseline.';
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
if (selected.has(key))
|
|
612
|
+
selected.delete(key);
|
|
613
|
+
else
|
|
614
|
+
selected.add(key);
|
|
615
|
+
warning = '';
|
|
616
|
+
};
|
|
617
|
+
const toggleAll = () => {
|
|
618
|
+
const optionalKeys = CONNECTOR_KEYS.filter((key) => !required.has(key));
|
|
619
|
+
const allOptionalSelected = optionalKeys.every((key) => selected.has(key));
|
|
620
|
+
if (allOptionalSelected)
|
|
621
|
+
optionalKeys.forEach((key) => selected.delete(key));
|
|
622
|
+
else
|
|
623
|
+
optionalKeys.forEach((key) => selected.add(key));
|
|
624
|
+
required.forEach((key) => selected.add(key));
|
|
625
|
+
warning = '';
|
|
626
|
+
};
|
|
627
|
+
const onKeypress = (_text, key) => {
|
|
628
|
+
if (key?.ctrl && key?.name === 'c') {
|
|
629
|
+
cancel();
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
if (key?.name === 'escape' || key?.name === 'q') {
|
|
633
|
+
cancel();
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
if (key?.name === 'up' || key?.name === 'k') {
|
|
637
|
+
const itemCount = displayItems().length || CONNECTOR_DEFINITIONS.length;
|
|
638
|
+
cursorIndex = (cursorIndex - 1 + itemCount) % itemCount;
|
|
639
|
+
warning = '';
|
|
640
|
+
}
|
|
641
|
+
else if (key?.name === 'down' || key?.name === 'j') {
|
|
642
|
+
const itemCount = displayItems().length || CONNECTOR_DEFINITIONS.length;
|
|
643
|
+
cursorIndex = (cursorIndex + 1) % itemCount;
|
|
644
|
+
warning = '';
|
|
645
|
+
}
|
|
646
|
+
else if (key?.name === 'space') {
|
|
647
|
+
toggleCurrent();
|
|
648
|
+
}
|
|
649
|
+
else if (key?.name === 'a') {
|
|
650
|
+
toggleAll();
|
|
651
|
+
}
|
|
652
|
+
else if (key?.name === 'return' || key?.name === 'enter') {
|
|
653
|
+
finish();
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
else if (/^[1-9]$/.test(String(_text || ''))) {
|
|
657
|
+
const index = Number(_text) - 1;
|
|
658
|
+
const connector = CONNECTOR_DEFINITIONS[index];
|
|
659
|
+
if (connector) {
|
|
660
|
+
cursorIndex = displayIndexForConnector(connector.key);
|
|
661
|
+
if (required.has(connector.key)) {
|
|
662
|
+
selected.add(connector.key);
|
|
663
|
+
warning = 'AnalyticsCLI is missing and required for the Growth Engineer baseline.';
|
|
664
|
+
}
|
|
665
|
+
else {
|
|
666
|
+
if (selected.has(connector.key))
|
|
667
|
+
selected.delete(connector.key);
|
|
668
|
+
else
|
|
669
|
+
selected.add(connector.key);
|
|
670
|
+
warning = '';
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
renderConnectorPicker(cursorIndex, selected, required, healthByConnector, warning);
|
|
675
|
+
};
|
|
676
|
+
process.stdin.on('keypress', onKeypress);
|
|
677
|
+
process.stdout.write(ANSI.hideCursor);
|
|
678
|
+
renderConnectorPicker(cursorIndex, selected, required, healthByConnector, warning);
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
async function commandExists(commandName) {
|
|
682
|
+
const result = await runInteractiveCommand(`command -v ${quote(commandName)} >/dev/null 2>&1`, {
|
|
683
|
+
silent: true,
|
|
684
|
+
});
|
|
685
|
+
return result === 0;
|
|
686
|
+
}
|
|
687
|
+
async function runInteractiveCommand(command, options = {}) {
|
|
688
|
+
return await new Promise((resolve) => {
|
|
689
|
+
const child = spawn('/bin/sh', ['-lc', command], {
|
|
690
|
+
env: options.env ?? process.env,
|
|
691
|
+
stdio: options.silent ? 'ignore' : 'inherit',
|
|
692
|
+
});
|
|
693
|
+
child.on('close', (code) => resolve(code));
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
async function runInteractiveProcess(command, args, options = {}) {
|
|
697
|
+
return await new Promise((resolve) => {
|
|
698
|
+
options.rl?.pause?.();
|
|
699
|
+
const child = spawn(command, args, {
|
|
700
|
+
env: options.env ?? process.env,
|
|
701
|
+
stdio: options.silent ? 'ignore' : 'inherit',
|
|
702
|
+
});
|
|
703
|
+
child.on('error', () => {
|
|
704
|
+
options.rl?.resume?.();
|
|
705
|
+
resolve(127);
|
|
706
|
+
});
|
|
707
|
+
child.on('close', (code) => {
|
|
708
|
+
options.rl?.resume?.();
|
|
709
|
+
resolve(code);
|
|
710
|
+
});
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
async function runCommandCapture(command, options = {}) {
|
|
714
|
+
return await new Promise((resolve) => {
|
|
715
|
+
const child = spawn('/bin/sh', ['-lc', command], {
|
|
716
|
+
env: options.env ?? process.env,
|
|
717
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
718
|
+
});
|
|
719
|
+
let stdout = '';
|
|
720
|
+
let stderr = '';
|
|
721
|
+
child.stdout.on('data', (chunk) => {
|
|
722
|
+
stdout += String(chunk);
|
|
723
|
+
});
|
|
724
|
+
child.stderr.on('data', (chunk) => {
|
|
725
|
+
stderr += String(chunk);
|
|
726
|
+
});
|
|
727
|
+
child.on('error', (error) => {
|
|
728
|
+
resolve({ ok: false, stdout, stderr: error.message, code: null });
|
|
729
|
+
});
|
|
730
|
+
child.on('close', (code) => {
|
|
731
|
+
resolve({ ok: code === 0, stdout, stderr, code });
|
|
732
|
+
});
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
async function runCommandCaptureWithTimeout(command, options = {}) {
|
|
736
|
+
return await new Promise((resolve) => {
|
|
737
|
+
const child = spawn('/bin/sh', ['-lc', command], {
|
|
738
|
+
env: options.env ?? process.env,
|
|
739
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
740
|
+
});
|
|
741
|
+
let stdout = '';
|
|
742
|
+
let stderr = '';
|
|
743
|
+
let settled = false;
|
|
744
|
+
const timer = setTimeout(() => {
|
|
745
|
+
if (settled)
|
|
746
|
+
return;
|
|
747
|
+
settled = true;
|
|
748
|
+
child.kill('SIGTERM');
|
|
749
|
+
resolve({ ok: false, stdout, stderr: `${stderr}\nTimed out after ${options.timeoutMs}ms`, code: null });
|
|
750
|
+
}, options.timeoutMs ?? 60_000);
|
|
751
|
+
child.stdout.on('data', (chunk) => {
|
|
752
|
+
stdout += String(chunk);
|
|
753
|
+
});
|
|
754
|
+
child.stderr.on('data', (chunk) => {
|
|
755
|
+
stderr += String(chunk);
|
|
756
|
+
});
|
|
757
|
+
child.on('error', (error) => {
|
|
758
|
+
if (settled)
|
|
759
|
+
return;
|
|
760
|
+
settled = true;
|
|
761
|
+
clearTimeout(timer);
|
|
762
|
+
resolve({ ok: false, stdout, stderr: error.message, code: null });
|
|
763
|
+
});
|
|
764
|
+
child.on('close', (code) => {
|
|
765
|
+
if (settled)
|
|
766
|
+
return;
|
|
767
|
+
settled = true;
|
|
768
|
+
clearTimeout(timer);
|
|
769
|
+
resolve({ ok: code === 0, stdout, stderr, code });
|
|
770
|
+
});
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
async function runCommandCaptureWithProgress(command, onProgress, options = {}) {
|
|
774
|
+
return await new Promise((resolve) => {
|
|
775
|
+
const child = spawn('/bin/sh', ['-lc', command], {
|
|
776
|
+
env: options.env ?? process.env,
|
|
777
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
778
|
+
});
|
|
779
|
+
let stdout = '';
|
|
780
|
+
let stderr = '';
|
|
781
|
+
let stderrBuffer = '';
|
|
782
|
+
let settled = false;
|
|
783
|
+
const timeoutMs = options.timeoutMs ?? 180_000;
|
|
784
|
+
const timer = setTimeout(() => {
|
|
785
|
+
if (settled)
|
|
786
|
+
return;
|
|
787
|
+
settled = true;
|
|
788
|
+
child.kill('SIGTERM');
|
|
789
|
+
resolve({ ok: false, stdout, stderr: `${stderr}\nTimed out after ${timeoutMs}ms`, code: null });
|
|
790
|
+
}, timeoutMs);
|
|
791
|
+
child.stdout.on('data', (chunk) => {
|
|
792
|
+
stdout += String(chunk);
|
|
793
|
+
});
|
|
794
|
+
child.stderr.on('data', (chunk) => {
|
|
795
|
+
const text = String(chunk);
|
|
796
|
+
stderr += text;
|
|
797
|
+
stderrBuffer += text;
|
|
798
|
+
const lines = stderrBuffer.split(/\r?\n/);
|
|
799
|
+
stderrBuffer = lines.pop() || '';
|
|
800
|
+
for (const line of lines) {
|
|
801
|
+
const match = line.match(/^OPENCLAW_PROGRESS\s+(.+)$/);
|
|
802
|
+
if (!match)
|
|
803
|
+
continue;
|
|
804
|
+
try {
|
|
805
|
+
onProgress(JSON.parse(match[1]));
|
|
806
|
+
}
|
|
807
|
+
catch {
|
|
808
|
+
// Ignore malformed progress events; the final JSON result is authoritative.
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
});
|
|
812
|
+
child.on('error', (error) => {
|
|
813
|
+
if (settled)
|
|
814
|
+
return;
|
|
815
|
+
settled = true;
|
|
816
|
+
clearTimeout(timer);
|
|
817
|
+
resolve({ ok: false, stdout, stderr: error.message, code: null });
|
|
818
|
+
});
|
|
819
|
+
child.on('close', (code) => {
|
|
820
|
+
if (settled)
|
|
821
|
+
return;
|
|
822
|
+
settled = true;
|
|
823
|
+
clearTimeout(timer);
|
|
824
|
+
const match = stderrBuffer.match(/^OPENCLAW_PROGRESS\s+(.+)$/);
|
|
825
|
+
if (match) {
|
|
826
|
+
try {
|
|
827
|
+
onProgress(JSON.parse(match[1]));
|
|
828
|
+
}
|
|
829
|
+
catch {
|
|
830
|
+
// Ignore malformed progress events; the final JSON result is authoritative.
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
resolve({ ok: code === 0, stdout, stderr, code });
|
|
834
|
+
});
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
function truncate(value, maxLength = 900) {
|
|
838
|
+
const text = String(value || '').trim();
|
|
839
|
+
if (text.length <= maxLength)
|
|
840
|
+
return text;
|
|
841
|
+
return `${text.slice(0, maxLength - 1)}...`;
|
|
842
|
+
}
|
|
843
|
+
function parseJsonFromStdout(stdout) {
|
|
844
|
+
const raw = String(stdout || '').trim();
|
|
845
|
+
if (!raw)
|
|
846
|
+
return null;
|
|
847
|
+
const firstBrace = raw.indexOf('{');
|
|
848
|
+
const firstBracket = raw.indexOf('[');
|
|
849
|
+
const starts = [firstBrace, firstBracket].filter((index) => index >= 0);
|
|
850
|
+
if (starts.length === 0)
|
|
851
|
+
return null;
|
|
852
|
+
try {
|
|
853
|
+
return JSON.parse(raw.slice(Math.min(...starts)));
|
|
854
|
+
}
|
|
855
|
+
catch {
|
|
856
|
+
return null;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
function clearTerminal() {
|
|
860
|
+
if (process.stdout.isTTY) {
|
|
861
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
function printConnectorSetupProgress(payload) {
|
|
865
|
+
const connectorSetup = Array.isArray(payload?.connectorSetup) ? payload.connectorSetup : [];
|
|
866
|
+
const okConnectors = connectorSetup.filter((entry) => entry?.ok).map((entry) => entry.connector).filter(Boolean);
|
|
867
|
+
if (okConnectors.length > 0) {
|
|
868
|
+
process.stdout.write(`Configured locally: ${okConnectors.map(connectorTitle).join(', ')}.\n`);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
function checkConnectorKey(check) {
|
|
872
|
+
return connectorFromCheckName(`${check?.name || ''} ${check?.detail || ''}`);
|
|
873
|
+
}
|
|
874
|
+
function getConfiguredConnectorKeys(payload) {
|
|
875
|
+
const connectorSetup = Array.isArray(payload?.connectorSetup) ? payload.connectorSetup : [];
|
|
876
|
+
return new Set(connectorSetup
|
|
877
|
+
.filter((entry) => entry?.ok)
|
|
878
|
+
.map((entry) => entry.connector)
|
|
879
|
+
.filter(Boolean));
|
|
880
|
+
}
|
|
881
|
+
function getPassingConnectorKeys(payload, failedConnectors = new Set()) {
|
|
882
|
+
const checks = Array.isArray(payload?.checks) ? payload.checks : [];
|
|
883
|
+
const configuredConnectors = getConfiguredConnectorKeys(payload);
|
|
884
|
+
const passing = new Set();
|
|
885
|
+
for (const check of checks) {
|
|
886
|
+
if (check?.status !== 'pass')
|
|
887
|
+
continue;
|
|
888
|
+
const connector = checkConnectorKey(check);
|
|
889
|
+
if (!connector || failedConnectors.has(connector))
|
|
890
|
+
continue;
|
|
891
|
+
if (configuredConnectors.size > 0 && !configuredConnectors.has(connector))
|
|
892
|
+
continue;
|
|
893
|
+
passing.add(connector);
|
|
894
|
+
}
|
|
895
|
+
return orderConnectors([...passing]);
|
|
896
|
+
}
|
|
897
|
+
function summarizeFailureReason(detail) {
|
|
898
|
+
const text = String(detail || '').replace(/\s+/g, ' ').trim();
|
|
899
|
+
if (/token has been revoked/i.test(text))
|
|
900
|
+
return 'token has been revoked';
|
|
901
|
+
if (/unauthorized|UNAUTHORIZED/i.test(text))
|
|
902
|
+
return 'token is unauthorized';
|
|
903
|
+
if (/Sentry API 404|Not Found/i.test(text))
|
|
904
|
+
return 'API returned 404 Not Found';
|
|
905
|
+
if (/project\.githubRepo is missing/i.test(text))
|
|
906
|
+
return 'GitHub repo is not configured';
|
|
907
|
+
if (/missing/i.test(text))
|
|
908
|
+
return text;
|
|
909
|
+
return cleanHealthDetail(text);
|
|
910
|
+
}
|
|
911
|
+
function summarizeFailureFix(connector, blockers) {
|
|
912
|
+
const combined = blockers.map((blocker) => `${blocker.check || ''} ${blocker.detail || ''}`).join('\n');
|
|
913
|
+
if (connector === 'analytics') {
|
|
914
|
+
if (/revoked|unauthorized|UNAUTHORIZED/i.test(combined)) {
|
|
915
|
+
return 'Paste a fresh AnalyticsCLI readonly CLI token in the wizard, then let setup retest.';
|
|
916
|
+
}
|
|
917
|
+
return 'Verify the AnalyticsCLI token can list projects. Per-project query failures are reported as warnings and should not block connector setup.';
|
|
918
|
+
}
|
|
919
|
+
if (connector === 'sentry') {
|
|
920
|
+
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.';
|
|
922
|
+
}
|
|
923
|
+
return 'Verify the Sentry/GlitchTip token, base URL, org, and project list, then rerun setup.';
|
|
924
|
+
}
|
|
925
|
+
if (connector === 'github') {
|
|
926
|
+
return 'Set project.githubRepo only if you want GitHub issue/PR delivery now; otherwise leave GitHub deferred.';
|
|
927
|
+
}
|
|
928
|
+
if (connector === 'revenuecat') {
|
|
929
|
+
return 'Paste a RevenueCat v2 secret API key with read-only project permissions, then rerun setup.';
|
|
930
|
+
}
|
|
931
|
+
if (connector === 'asc') {
|
|
932
|
+
return 'Rerun ASC setup and verify ASC credentials, key role access, and `asc apps list --output json`.';
|
|
933
|
+
}
|
|
934
|
+
return blockers.find((blocker) => blocker.remediation)?.remediation || 'Fix the failing configuration and rerun setup.';
|
|
935
|
+
}
|
|
936
|
+
function connectorForBlocker(blocker) {
|
|
937
|
+
return connectorFromCheckName(`${blocker?.check || ''} ${blocker?.detail || ''}`) || 'setup';
|
|
938
|
+
}
|
|
939
|
+
function groupBlockersByConnector(blockers, focusConnectors = null) {
|
|
940
|
+
const groups = new Map();
|
|
941
|
+
const focus = focusConnectors ? new Set(focusConnectors) : null;
|
|
942
|
+
for (const blocker of blockers) {
|
|
943
|
+
if (isDeferredGitHubFailure(blocker))
|
|
944
|
+
continue;
|
|
945
|
+
const connector = connectorForBlocker(blocker);
|
|
946
|
+
if (focus && !focus.has(connector))
|
|
947
|
+
continue;
|
|
948
|
+
const entries = groups.get(connector) || [];
|
|
949
|
+
entries.push(blocker);
|
|
950
|
+
groups.set(connector, entries);
|
|
951
|
+
}
|
|
952
|
+
return groups;
|
|
953
|
+
}
|
|
954
|
+
function printDeferredSetupNotes(blockers, focusConnectors = null) {
|
|
955
|
+
const focus = focusConnectors ? new Set(focusConnectors) : null;
|
|
956
|
+
const deferredGitHub = blockers.some((blocker) => isDeferredGitHubFailure(blocker));
|
|
957
|
+
if (!deferredGitHub || (focus && !focus.has('github')))
|
|
958
|
+
return;
|
|
959
|
+
process.stdout.write('\nDeferred / optional:\n');
|
|
960
|
+
process.stdout.write('- GitHub: repo is not configured. This is only needed for GitHub issue/PR delivery.\n');
|
|
961
|
+
}
|
|
962
|
+
function printConciseSetupBlockers(payload, command, options = {}) {
|
|
963
|
+
const blockers = Array.isArray(payload?.blockers) ? payload.blockers : [];
|
|
964
|
+
const focusConnectors = Array.isArray(options.focusConnectors) ? options.focusConnectors : null;
|
|
965
|
+
const groups = groupBlockersByConnector(blockers, focusConnectors);
|
|
966
|
+
const failedConnectors = new Set([...groups.keys()].filter((key) => key !== 'setup'));
|
|
967
|
+
let passingConnectors = getPassingConnectorKeys(payload, failedConnectors);
|
|
968
|
+
if (focusConnectors) {
|
|
969
|
+
const focus = new Set(focusConnectors);
|
|
970
|
+
passingConnectors = passingConnectors.filter((connector) => focus.has(connector));
|
|
971
|
+
}
|
|
972
|
+
if (passingConnectors.length > 0) {
|
|
973
|
+
process.stdout.write(`Live checks passed: ${passingConnectors.map(connectorTitle).join(', ')}.\n`);
|
|
974
|
+
}
|
|
975
|
+
if (groups.size > 0) {
|
|
976
|
+
process.stdout.write('\nNeeds fix:\n');
|
|
977
|
+
for (const [connector, connectorBlockers] of groups.entries()) {
|
|
978
|
+
const primary = connectorBlockers[0] || {};
|
|
979
|
+
const reasons = [
|
|
980
|
+
...new Set(connectorBlockers
|
|
981
|
+
.map((blocker) => summarizeFailureReason(blocker.detail || blocker.check))
|
|
982
|
+
.filter(Boolean)),
|
|
983
|
+
];
|
|
984
|
+
process.stdout.write(`- ${connectorTitle(connector)}: ${summarizeFailureReason(primary.detail || primary.check)}\n`);
|
|
985
|
+
process.stdout.write(` Why: ${reasons.join('; ')}.\n`);
|
|
986
|
+
process.stdout.write(` Fix: ${summarizeFailureFix(connector, connectorBlockers)}\n`);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
printDeferredSetupNotes(blockers, focusConnectors);
|
|
990
|
+
if (groups.size > 0 || !options.hideRerunWhenClean) {
|
|
991
|
+
process.stdout.write(`\nRerun: ${command}\n`);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
function payloadHasConnectorFailures(payload, connector) {
|
|
995
|
+
const blockers = Array.isArray(payload?.blockers) ? payload.blockers : [];
|
|
996
|
+
return blockers.some((blocker) => !isDeferredGitHubFailure(blocker) && connectorForBlocker(blocker) === connector);
|
|
997
|
+
}
|
|
998
|
+
async function askListSelection(rl, label, entries, options = {}) {
|
|
999
|
+
const includeManual = Boolean(options.includeManual);
|
|
1000
|
+
const includeDefer = Boolean(options.includeDefer);
|
|
1001
|
+
entries.forEach((entry, index) => {
|
|
1002
|
+
const description = entry.description ? ` - ${entry.description}` : '';
|
|
1003
|
+
process.stdout.write(` ${index + 1}) ${entry.label}${description}\n`);
|
|
1004
|
+
});
|
|
1005
|
+
const manualIndex = includeManual ? entries.length + 1 : null;
|
|
1006
|
+
const deferIndex = includeDefer ? entries.length + (includeManual ? 2 : 1) : null;
|
|
1007
|
+
if (manualIndex)
|
|
1008
|
+
process.stdout.write(` ${manualIndex}) Enter manually\n`);
|
|
1009
|
+
if (deferIndex)
|
|
1010
|
+
process.stdout.write(` ${deferIndex}) Defer\n`);
|
|
1011
|
+
while (true) {
|
|
1012
|
+
const answer = (await ask(rl, label, entries.length === 1 ? '1' : '')).trim();
|
|
1013
|
+
const numericIndex = Number.parseInt(answer, 10);
|
|
1014
|
+
if (Number.isInteger(numericIndex)) {
|
|
1015
|
+
if (numericIndex >= 1 && numericIndex <= entries.length)
|
|
1016
|
+
return entries[numericIndex - 1].value;
|
|
1017
|
+
if (manualIndex && numericIndex === manualIndex)
|
|
1018
|
+
return '__manual__';
|
|
1019
|
+
if (deferIndex && numericIndex === deferIndex)
|
|
1020
|
+
return '';
|
|
1021
|
+
}
|
|
1022
|
+
const matchingEntry = entries.find((entry) => [entry.value, entry.label].some((value) => String(value || '').toLowerCase() === answer.toLowerCase()));
|
|
1023
|
+
if (matchingEntry)
|
|
1024
|
+
return matchingEntry.value;
|
|
1025
|
+
process.stdout.write('Choose one of the listed numbers.\n');
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
function printSetupFailure({ result, payload, command }) {
|
|
1029
|
+
process.stdout.write('\nFAILED: Connector setup needs attention.\n');
|
|
1030
|
+
printConnectorSetupProgress(payload);
|
|
1031
|
+
const blockers = Array.isArray(payload?.blockers) ? payload.blockers : [];
|
|
1032
|
+
if (blockers.length > 0) {
|
|
1033
|
+
printConciseSetupBlockers(payload, command);
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
const reason = result.code === null ? 'setup command did not report an exit code' : `setup command exited with code ${result.code}`;
|
|
1037
|
+
process.stdout.write(`Reason: ${reason}.\n`);
|
|
1038
|
+
const output = truncate(result.stderr || result.stdout);
|
|
1039
|
+
if (output) {
|
|
1040
|
+
process.stdout.write(`Details: ${output}\n`);
|
|
1041
|
+
}
|
|
1042
|
+
process.stdout.write(`Run manually for full output: ${command}\n`);
|
|
1043
|
+
}
|
|
1044
|
+
function printSetupSuccess(payload) {
|
|
1045
|
+
process.stdout.write('\nSUCCESS: Connector setup finished.\n');
|
|
1046
|
+
printConnectorSetupProgress(payload);
|
|
1047
|
+
if (payload?.message) {
|
|
1048
|
+
process.stdout.write(`${payload.message}\n`);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
function healthCheckFailures(payload) {
|
|
1052
|
+
return Array.isArray(payload?.checks)
|
|
1053
|
+
? payload.checks.filter((check) => check?.status === 'fail')
|
|
1054
|
+
: [];
|
|
1055
|
+
}
|
|
1056
|
+
function connectorFromCheckName(name) {
|
|
1057
|
+
const value = String(name || '');
|
|
1058
|
+
if (value.includes('analytics') || value.includes('ANALYTICSCLI'))
|
|
1059
|
+
return 'analytics';
|
|
1060
|
+
if (value.includes('github') || value.includes('GITHUB'))
|
|
1061
|
+
return 'github';
|
|
1062
|
+
if (value.includes('revenuecat') || value.includes('REVENUECAT'))
|
|
1063
|
+
return 'revenuecat';
|
|
1064
|
+
if (value.includes('sentry') || value.includes('SENTRY') || value.includes('GLITCHTIP'))
|
|
1065
|
+
return 'sentry';
|
|
1066
|
+
if (value.includes('asc') || value.includes('ASC_'))
|
|
1067
|
+
return 'asc';
|
|
1068
|
+
return null;
|
|
1069
|
+
}
|
|
1070
|
+
function connectorTitle(key) {
|
|
1071
|
+
return CONNECTOR_DEFINITIONS.find((connector) => connector.key === key)?.label || key || 'General setup';
|
|
1072
|
+
}
|
|
1073
|
+
function compactJsonError(value) {
|
|
1074
|
+
const text = String(value || '');
|
|
1075
|
+
const jsonStart = text.indexOf('{"error"');
|
|
1076
|
+
if (jsonStart < 0)
|
|
1077
|
+
return '';
|
|
1078
|
+
try {
|
|
1079
|
+
const payload = JSON.parse(text.slice(jsonStart).replace(/\)+\s*$/g, '').trim());
|
|
1080
|
+
const error = payload?.error || payload;
|
|
1081
|
+
const parts = [
|
|
1082
|
+
error.code ? `code=${error.code}` : '',
|
|
1083
|
+
error.message ? `message=${error.message}` : '',
|
|
1084
|
+
error.details?.reason ? `reason=${error.details.reason}` : '',
|
|
1085
|
+
error.details?.upgradeUrl ? `upgradeUrl=${error.details.upgradeUrl}` : '',
|
|
1086
|
+
].filter(Boolean);
|
|
1087
|
+
return parts.join(', ');
|
|
1088
|
+
}
|
|
1089
|
+
catch {
|
|
1090
|
+
return '';
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
function cleanHealthDetail(detail) {
|
|
1094
|
+
const raw = String(detail || '').replace(/\s+/g, ' ').trim();
|
|
1095
|
+
const compactError = compactJsonError(raw);
|
|
1096
|
+
if (/project\.githubRepo is required/i.test(raw)) {
|
|
1097
|
+
return 'No GitHub repo is configured yet. This is optional unless you want GitHub issue/PR delivery now.';
|
|
1098
|
+
}
|
|
1099
|
+
if (/project\.githubRepo is missing/i.test(raw)) {
|
|
1100
|
+
return 'GitHub repo access test is deferred until a repo is known.';
|
|
1101
|
+
}
|
|
1102
|
+
if (/invalid token|unauthorized|token has been revoked/i.test(raw)) {
|
|
1103
|
+
return `AnalyticsCLI token is invalid${compactError ? ` (${compactError})` : ''}.`;
|
|
1104
|
+
}
|
|
1105
|
+
if (/No Sentry projects configured/i.test(raw)) {
|
|
1106
|
+
return 'Sentry project scope is deferred; the AI can discover visible projects from org + token.';
|
|
1107
|
+
}
|
|
1108
|
+
if (/smoke test failed/i.test(raw)) {
|
|
1109
|
+
const withoutWrappedJson = raw.replace(/\{"error".*$/, '').replace(/\s*\(+\s*$/, '').trim();
|
|
1110
|
+
return withoutWrappedJson || raw;
|
|
1111
|
+
}
|
|
1112
|
+
return truncate(raw, 180);
|
|
1113
|
+
}
|
|
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
|
+
function isDeferredGitHubFailure(failure) {
|
|
1138
|
+
const name = String(failure?.name || '');
|
|
1139
|
+
const detail = String(failure?.detail || '');
|
|
1140
|
+
return (name === 'project:github-repo' ||
|
|
1141
|
+
(name === 'connection:github' && /project\.githubRepo|repo is missing|repo is not configured/i.test(detail)));
|
|
1142
|
+
}
|
|
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
|
+
function healthStatusLabel(status) {
|
|
1255
|
+
if (status === 'running')
|
|
1256
|
+
return 'running';
|
|
1257
|
+
if (status === 'pass')
|
|
1258
|
+
return 'done';
|
|
1259
|
+
if (status === 'warn')
|
|
1260
|
+
return 'needs attention';
|
|
1261
|
+
if (status === 'fail')
|
|
1262
|
+
return 'needs attention';
|
|
1263
|
+
if (status === 'deferred')
|
|
1264
|
+
return 'deferred';
|
|
1265
|
+
return 'pending';
|
|
1266
|
+
}
|
|
1267
|
+
function renderHealthProgress(items, message = 'Live checks running...', title = 'Health check') {
|
|
1268
|
+
if (process.stdout.isTTY)
|
|
1269
|
+
clearTerminal();
|
|
1270
|
+
const finished = items.filter((item) => !['pending', 'running'].includes(String(item.status || ''))).length;
|
|
1271
|
+
process.stdout.write(`${title}\n`);
|
|
1272
|
+
process.stdout.write('------------\n');
|
|
1273
|
+
process.stdout.write(`${message}\n\n`);
|
|
1274
|
+
process.stdout.write(`${finished}/${items.length} checks finished.\n\n`);
|
|
1275
|
+
for (const item of items) {
|
|
1276
|
+
process.stdout.write(`[${healthStatusLabel(item.status)}] ${item.label}: ${item.detail}\n`);
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
function updateHealthProgress(items, event) {
|
|
1280
|
+
const key = String(event?.key || '');
|
|
1281
|
+
const item = items.find((entry) => entry.key === key);
|
|
1282
|
+
if (!item)
|
|
1283
|
+
return false;
|
|
1284
|
+
if (event.phase === 'start') {
|
|
1285
|
+
item.status = 'running';
|
|
1286
|
+
if (event.detail)
|
|
1287
|
+
item.detail = String(event.detail);
|
|
1288
|
+
if (event.label)
|
|
1289
|
+
item.label = String(event.label);
|
|
1290
|
+
return true;
|
|
1291
|
+
}
|
|
1292
|
+
if (event.phase === 'finish') {
|
|
1293
|
+
item.status = event.status || 'pass';
|
|
1294
|
+
if (event.detail)
|
|
1295
|
+
item.detail = String(event.detail);
|
|
1296
|
+
if (event.label)
|
|
1297
|
+
item.label = String(event.label);
|
|
1298
|
+
return true;
|
|
1299
|
+
}
|
|
1300
|
+
return false;
|
|
1301
|
+
}
|
|
1302
|
+
function allProgressItemsFinished(items) {
|
|
1303
|
+
return items.length > 0 && items.every((item) => !['pending', 'running'].includes(String(item.status || '')));
|
|
1304
|
+
}
|
|
1305
|
+
function buildSetupTestProgressPlan(selected) {
|
|
1306
|
+
const selectedSet = new Set(selected);
|
|
1307
|
+
const items = [
|
|
1308
|
+
{
|
|
1309
|
+
key: 'connectorSetup',
|
|
1310
|
+
label: 'Connector helpers',
|
|
1311
|
+
detail: 'waiting to install and enable selected helpers',
|
|
1312
|
+
status: 'pending',
|
|
1313
|
+
},
|
|
1314
|
+
{
|
|
1315
|
+
key: 'analyticsProject',
|
|
1316
|
+
label: 'AnalyticsCLI scope',
|
|
1317
|
+
detail: 'waiting to check accessible analytics projects',
|
|
1318
|
+
status: 'pending',
|
|
1319
|
+
},
|
|
1320
|
+
];
|
|
1321
|
+
if (selectedSet.has('asc')) {
|
|
1322
|
+
items.push({
|
|
1323
|
+
key: 'ascApp',
|
|
1324
|
+
label: 'ASC app scope',
|
|
1325
|
+
detail: 'waiting to resolve App Store Connect app scope',
|
|
1326
|
+
status: 'pending',
|
|
1327
|
+
});
|
|
1328
|
+
}
|
|
1329
|
+
items.push({
|
|
1330
|
+
key: 'preflight',
|
|
1331
|
+
label: 'Local preflight',
|
|
1332
|
+
detail: 'waiting to validate config, dependencies, and source wiring',
|
|
1333
|
+
status: 'pending',
|
|
1334
|
+
});
|
|
1335
|
+
if (selectedSet.has('analytics')) {
|
|
1336
|
+
items.push({ key: 'analytics', label: 'AnalyticsCLI', detail: 'waiting for token auth + readonly query', status: 'pending' });
|
|
1337
|
+
}
|
|
1338
|
+
if (selectedSet.has('sentry')) {
|
|
1339
|
+
items.push({ key: 'sentry', label: 'Sentry / GlitchTip', detail: 'waiting for token/org API + project discovery', status: 'pending' });
|
|
1340
|
+
}
|
|
1341
|
+
if (selectedSet.has('revenuecat')) {
|
|
1342
|
+
items.push({ key: 'revenuecat', label: 'RevenueCat', detail: 'waiting for API key auth + project read', status: 'pending' });
|
|
1343
|
+
}
|
|
1344
|
+
if (selectedSet.has('github')) {
|
|
1345
|
+
items.push({ key: 'github', label: 'GitHub', detail: 'waiting for repo/token access check', status: 'pending' });
|
|
1346
|
+
}
|
|
1347
|
+
items.push({
|
|
1348
|
+
key: 'finalize',
|
|
1349
|
+
label: 'Finalizing result',
|
|
1350
|
+
detail: 'waiting for command output, parsing, and follow-up checks',
|
|
1351
|
+
status: 'pending',
|
|
1352
|
+
});
|
|
1353
|
+
return items;
|
|
1354
|
+
}
|
|
1355
|
+
function primaryProgressItemsFinished(items) {
|
|
1356
|
+
return items
|
|
1357
|
+
.filter((item) => item.key !== 'finalize')
|
|
1358
|
+
.every((item) => !['pending', 'running'].includes(String(item.status || '')));
|
|
1359
|
+
}
|
|
1360
|
+
function updateProgressItem(items, key, status, detail) {
|
|
1361
|
+
const item = items.find((entry) => entry.key === key);
|
|
1362
|
+
if (!item)
|
|
1363
|
+
return;
|
|
1364
|
+
item.status = status;
|
|
1365
|
+
if (detail)
|
|
1366
|
+
item.detail = detail;
|
|
1367
|
+
}
|
|
1368
|
+
async function runSetupCommandWithProgress(command, env, selected, message) {
|
|
1369
|
+
const plan = buildSetupTestProgressPlan(selected);
|
|
1370
|
+
renderHealthProgress(plan, `${message}\nDo not close this terminal yet.`, 'Connector setup test');
|
|
1371
|
+
const progressCommand = command.includes('--progress-json') ? command : `${command} --progress-json`;
|
|
1372
|
+
const result = await runCommandCaptureWithProgress(progressCommand, (event) => {
|
|
1373
|
+
if (updateHealthProgress(plan, event)) {
|
|
1374
|
+
const primaryFinished = primaryProgressItemsFinished(plan);
|
|
1375
|
+
if (primaryFinished) {
|
|
1376
|
+
updateProgressItem(plan, 'finalize', 'running', 'command still running; parsing final output and follow-up work');
|
|
1377
|
+
}
|
|
1378
|
+
const message = primaryFinished
|
|
1379
|
+
? 'Checks finished. Finalizing result; do not close this terminal yet.'
|
|
1380
|
+
: 'Connector setup test is still running. Do not close this terminal yet.';
|
|
1381
|
+
renderHealthProgress(plan, message, 'Connector setup test');
|
|
1382
|
+
}
|
|
1383
|
+
}, { env, timeoutMs: 180_000 });
|
|
1384
|
+
updateProgressItem(plan, 'finalize', 'pass', 'result received');
|
|
1385
|
+
renderHealthProgress(plan, 'Connector setup test finished.', 'Connector setup test');
|
|
1386
|
+
return result;
|
|
1387
|
+
}
|
|
1388
|
+
async function saveSecretsImmediately(secrets) {
|
|
1389
|
+
if (Object.keys(secrets).length === 0)
|
|
1390
|
+
return false;
|
|
1391
|
+
const secretsFile = resolveSecretsFile();
|
|
1392
|
+
await writeSecretsFile(secretsFile, secrets);
|
|
1393
|
+
Object.assign(process.env, secrets);
|
|
1394
|
+
process.stdout.write(`Saved local secrets to ${secretsFile} with chmod 600.\n`);
|
|
1395
|
+
return true;
|
|
1396
|
+
}
|
|
1397
|
+
async function runImmediateConnectorHealthCheck({ rl, configPath, connector, secrets, sentryAccounts = [], }) {
|
|
1398
|
+
if (connector === 'sentry' && sentryAccounts.length > 0) {
|
|
1399
|
+
await upsertSentryAccountsConfig(configPath, sentryAccounts);
|
|
1400
|
+
}
|
|
1401
|
+
await saveSecretsImmediately(secrets);
|
|
1402
|
+
const env = {
|
|
1403
|
+
...process.env,
|
|
1404
|
+
...secrets,
|
|
1405
|
+
};
|
|
1406
|
+
const command = `node scripts/openclaw-growth-start.mjs --config ${quote(configPath)} --setup-only --connectors ${quote(connector)} --only-connectors ${quote(connector)}`;
|
|
1407
|
+
let result = await runSetupCommandWithProgress(command, env, [connector], `Checking ${connectorLabel(connector)} immediately after setup...`);
|
|
1408
|
+
let payload = parseJsonFromStdout(result.stdout);
|
|
1409
|
+
if (connector === 'asc') {
|
|
1410
|
+
try {
|
|
1411
|
+
const ascWebAuthChanged = await ensureAscWebAnalyticsAuth(rl, secrets);
|
|
1412
|
+
if (ascWebAuthChanged) {
|
|
1413
|
+
result = await runSetupCommandWithProgress(command, env, [connector], 'Retesting ASC after web analytics login...');
|
|
1414
|
+
payload = parseJsonFromStdout(result.stdout);
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
catch (error) {
|
|
1418
|
+
process.stdout.write(`ASC web analytics still needs attention: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
if (payloadHasConnectorFailures(payload, connector)) {
|
|
1422
|
+
process.stdout.write(`\n${connectorLabel(connector)} needs attention before continuing.\n`);
|
|
1423
|
+
printConciseSetupBlockers(payload, command, {
|
|
1424
|
+
focusConnectors: [connector],
|
|
1425
|
+
hideRerunWhenClean: true,
|
|
1426
|
+
});
|
|
1427
|
+
const retry = await askYesNo(rl, `Re-enter ${connectorLabel(connector)} configuration now?`, true);
|
|
1428
|
+
return { ok: false, retry, result, payload };
|
|
1429
|
+
}
|
|
1430
|
+
process.stdout.write(`\n${connectorLabel(connector)} immediate health check passed or is only waiting on optional/deferred context.\n`);
|
|
1431
|
+
return { ok: true, retry: false, result, payload };
|
|
1432
|
+
}
|
|
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
|
+
function getUserLocalBinDir() {
|
|
1467
|
+
return process.env.HOME ? path.join(process.env.HOME, '.local', 'bin') : null;
|
|
1468
|
+
}
|
|
1469
|
+
function prependPath(dir) {
|
|
1470
|
+
const current = process.env.PATH || '';
|
|
1471
|
+
if (!current.split(':').includes(dir)) {
|
|
1472
|
+
process.env.PATH = `${dir}:${current}`;
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
function getGitHubCliReleaseAssetName(version) {
|
|
1476
|
+
const arch = process.arch === 'x64' ? 'amd64' : process.arch === 'arm64' ? 'arm64' : '';
|
|
1477
|
+
if (process.platform === 'linux' && arch) {
|
|
1478
|
+
return `gh_${version}_linux_${arch}.tar.gz`;
|
|
1479
|
+
}
|
|
1480
|
+
return null;
|
|
1481
|
+
}
|
|
1482
|
+
async function resolveGitHubCliReleaseAssetUrl() {
|
|
1483
|
+
const response = await fetch('https://api.github.com/repos/cli/cli/releases/latest', {
|
|
1484
|
+
headers: {
|
|
1485
|
+
Accept: 'application/vnd.github+json',
|
|
1486
|
+
'User-Agent': 'openclaw-growth-wizard',
|
|
1487
|
+
},
|
|
1488
|
+
});
|
|
1489
|
+
if (!response.ok) {
|
|
1490
|
+
throw new Error(`GitHub CLI release lookup failed (${response.status})`);
|
|
1491
|
+
}
|
|
1492
|
+
const release = await response.json();
|
|
1493
|
+
const version = String(release.tag_name || '').replace(/^v/, '');
|
|
1494
|
+
const assetName = getGitHubCliReleaseAssetName(version);
|
|
1495
|
+
if (!assetName) {
|
|
1496
|
+
throw new Error(`No user-local gh installer is defined for ${process.platform}/${process.arch}`);
|
|
1497
|
+
}
|
|
1498
|
+
const asset = release.assets?.find((entry) => entry.name === assetName);
|
|
1499
|
+
if (!asset?.browser_download_url) {
|
|
1500
|
+
throw new Error(`GitHub CLI release asset not found: ${assetName}`);
|
|
1501
|
+
}
|
|
1502
|
+
return asset.browser_download_url;
|
|
1503
|
+
}
|
|
1504
|
+
async function installGitHubCliUserLocal() {
|
|
1505
|
+
const binDir = getUserLocalBinDir();
|
|
1506
|
+
if (!binDir) {
|
|
1507
|
+
process.stdout.write('Cannot install gh automatically because HOME is not set.\n');
|
|
1508
|
+
return false;
|
|
1509
|
+
}
|
|
1510
|
+
if (!(await commandExists('curl'))) {
|
|
1511
|
+
process.stdout.write('Cannot install gh automatically because curl is not available.\n');
|
|
1512
|
+
return false;
|
|
1513
|
+
}
|
|
1514
|
+
if (!(await commandExists('tar'))) {
|
|
1515
|
+
process.stdout.write('Cannot install gh automatically because tar is not available.\n');
|
|
1516
|
+
return false;
|
|
1517
|
+
}
|
|
1518
|
+
try {
|
|
1519
|
+
const url = await resolveGitHubCliReleaseAssetUrl();
|
|
1520
|
+
const cacheDir = process.env.HOME
|
|
1521
|
+
? path.join(process.env.HOME, '.cache', 'openclaw-gh')
|
|
1522
|
+
: path.join(process.cwd(), '.openclaw-gh-cache');
|
|
1523
|
+
const command = [
|
|
1524
|
+
'set -eu',
|
|
1525
|
+
`mkdir -p ${quote(binDir)} ${quote(cacheDir)}`,
|
|
1526
|
+
`tmp="$(mktemp -d ${quote(path.join(cacheDir, 'gh.XXXXXX'))})"`,
|
|
1527
|
+
'trap \'rm -rf "$tmp"\' EXIT',
|
|
1528
|
+
`curl -fsSL ${quote(url)} -o "$tmp/gh.tar.gz"`,
|
|
1529
|
+
'tar -xzf "$tmp/gh.tar.gz" -C "$tmp"',
|
|
1530
|
+
'gh_bin="$(find "$tmp" -path "*/bin/gh" -type f | head -n 1)"',
|
|
1531
|
+
'test -n "$gh_bin"',
|
|
1532
|
+
`cp "$gh_bin" ${quote(path.join(binDir, 'gh'))}`,
|
|
1533
|
+
`chmod 755 ${quote(path.join(binDir, 'gh'))}`,
|
|
1534
|
+
'for profile in "$HOME/.profile" "$HOME/.bashrc" "$HOME/.bash_profile" "$HOME/.zshrc" "$HOME/.zprofile"; do touch "$profile"; grep -Fq \'export PATH="$HOME/.local/bin:$PATH"\' "$profile" || printf \'\\n# OpenClaw user-local bin\\nexport PATH="$HOME/.local/bin:$PATH"\\n\' >> "$profile"; done',
|
|
1535
|
+
].join(' && ');
|
|
1536
|
+
process.stdout.write(`Installing GitHub CLI locally into ${binDir}/gh...\n`);
|
|
1537
|
+
const code = await runInteractiveCommand(command);
|
|
1538
|
+
prependPath(binDir);
|
|
1539
|
+
return code === 0 && await commandExists('gh');
|
|
1540
|
+
}
|
|
1541
|
+
catch (error) {
|
|
1542
|
+
process.stdout.write(`Automatic gh install failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
1543
|
+
return false;
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
function parseGitHubRepoFromRemote(remoteUrl) {
|
|
1547
|
+
const value = String(remoteUrl || '').trim();
|
|
1548
|
+
if (!value)
|
|
1549
|
+
return null;
|
|
1550
|
+
const sshMatch = value.match(/^git@github\.com:([^/]+\/[^/]+?)(?:\.git)?$/i);
|
|
1551
|
+
if (sshMatch)
|
|
1552
|
+
return sshMatch[1];
|
|
1553
|
+
const httpsMatch = value.match(/^https?:\/\/github\.com\/([^/]+\/[^/]+?)(?:\.git)?$/i);
|
|
1554
|
+
if (httpsMatch)
|
|
1555
|
+
return httpsMatch[1];
|
|
1556
|
+
return null;
|
|
1557
|
+
}
|
|
1558
|
+
function isConfiguredGitHubRepo(value) {
|
|
1559
|
+
const repo = String(value || '').trim();
|
|
1560
|
+
return Boolean(repo && repo !== 'owner/repo' && /^[^/\s]+\/[^/\s]+$/.test(repo));
|
|
1561
|
+
}
|
|
1562
|
+
async function detectGitHubRepo() {
|
|
1563
|
+
const explicit = String(process.env.OPENCLAW_GITHUB_REPO || '').trim();
|
|
1564
|
+
if (isConfiguredGitHubRepo(explicit))
|
|
1565
|
+
return explicit;
|
|
1566
|
+
const remoteResult = await runCommandCapture('git config --get remote.origin.url');
|
|
1567
|
+
if (!remoteResult.ok)
|
|
1568
|
+
return null;
|
|
1569
|
+
return parseGitHubRepoFromRemote(remoteResult.stdout);
|
|
1570
|
+
}
|
|
1571
|
+
function resolveSecretsFile() {
|
|
1572
|
+
const explicit = process.env.OPENCLAW_GROWTH_SECRETS_FILE?.trim();
|
|
1573
|
+
if (explicit)
|
|
1574
|
+
return path.resolve(explicit);
|
|
1575
|
+
if (process.env.HOME)
|
|
1576
|
+
return path.join(process.env.HOME, '.config', 'openclaw-growth', 'secrets.env');
|
|
1577
|
+
return path.resolve('.openclaw-growth-secrets.env');
|
|
1578
|
+
}
|
|
1579
|
+
function resolveAscPrivateKeyPath(keyId) {
|
|
1580
|
+
const safeKeyId = (keyId || 'OPENCLAW').trim().replace(/[^a-zA-Z0-9_-]/g, '_') || 'OPENCLAW';
|
|
1581
|
+
const baseDir = process.env.HOME
|
|
1582
|
+
? path.join(process.env.HOME, '.config', 'openclaw-growth')
|
|
1583
|
+
: path.resolve('.openclaw-growth');
|
|
1584
|
+
return path.join(baseDir, `AuthKey_${safeKeyId}.p8`);
|
|
1585
|
+
}
|
|
1586
|
+
function renderEnvValue(value) {
|
|
1587
|
+
return `"${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$')}"`;
|
|
1588
|
+
}
|
|
1589
|
+
async function readSecretsFile(filePath) {
|
|
1590
|
+
const values = new Map();
|
|
1591
|
+
let raw = '';
|
|
1592
|
+
try {
|
|
1593
|
+
raw = await fs.readFile(filePath, 'utf8');
|
|
1594
|
+
}
|
|
1595
|
+
catch {
|
|
1596
|
+
return values;
|
|
1597
|
+
}
|
|
1598
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
1599
|
+
const match = line.match(/^\s*(?:export\s+)?([A-Z0-9_]+)=(.*)\s*$/);
|
|
1600
|
+
if (!match)
|
|
1601
|
+
continue;
|
|
1602
|
+
values.set(match[1], match[2].replace(/^"|"$/g, ''));
|
|
1603
|
+
}
|
|
1604
|
+
return values;
|
|
1605
|
+
}
|
|
1606
|
+
async function writeSecretsFile(filePath, nextValues) {
|
|
1607
|
+
const current = await readSecretsFile(filePath);
|
|
1608
|
+
for (const [key, value] of Object.entries(nextValues)) {
|
|
1609
|
+
if (value.trim())
|
|
1610
|
+
current.set(key, value.trim());
|
|
1611
|
+
}
|
|
1612
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
1613
|
+
const lines = [
|
|
1614
|
+
'# OpenClaw Growth local secrets.',
|
|
1615
|
+
'# This file is generated by openclaw-growth-wizard.mjs and should not be committed.',
|
|
1616
|
+
...[...current.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => `export ${key}=${renderEnvValue(value)}`),
|
|
1617
|
+
'',
|
|
1618
|
+
];
|
|
1619
|
+
await fs.writeFile(filePath, lines.join('\n'), { encoding: 'utf8', mode: 0o600 });
|
|
1620
|
+
await fs.chmod(filePath, 0o600);
|
|
1621
|
+
}
|
|
1622
|
+
function renderBashSingleQuoted(value) {
|
|
1623
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
1624
|
+
}
|
|
1625
|
+
function renderIsolatedSecretRunnerInstallScript({ workspaceRoot, configPath, serviceUser, agentUser, }) {
|
|
1626
|
+
const workspaceLiteral = renderBashSingleQuoted(workspaceRoot);
|
|
1627
|
+
const configLiteral = renderBashSingleQuoted(path.relative(workspaceRoot, configPath) || configPath);
|
|
1628
|
+
const serviceUserLiteral = renderBashSingleQuoted(serviceUser);
|
|
1629
|
+
const agentUserLiteral = renderBashSingleQuoted(agentUser);
|
|
1630
|
+
return `#!/usr/bin/env bash
|
|
1631
|
+
set -euo pipefail
|
|
1632
|
+
|
|
1633
|
+
SERVICE_USER=\${OPENCLAW_GROWTH_SERVICE_USER:-${serviceUserLiteral}}
|
|
1634
|
+
AGENT_USER=\${OPENCLAW_GROWTH_AGENT_USER:-${agentUserLiteral}}
|
|
1635
|
+
WORKSPACE=${workspaceLiteral}
|
|
1636
|
+
CONFIG_PATH=\${OPENCLAW_GROWTH_CONFIG_PATH:-${configLiteral}}
|
|
1637
|
+
STATE_PATH=\${OPENCLAW_GROWTH_STATE_PATH:-data/openclaw-growth-engineer/state.json}
|
|
1638
|
+
RUNTIME_DIR=/var/lib/openclaw-growth
|
|
1639
|
+
SECRETS_FILE="\${RUNTIME_DIR}/secrets.env"
|
|
1640
|
+
LOCAL_SECRETS_FILE="\${OPENCLAW_GROWTH_LOCAL_SECRETS_FILE:-\${HOME}/.config/openclaw-growth/secrets.env}"
|
|
1641
|
+
SUDOERS_FILE=/etc/sudoers.d/openclaw-growth
|
|
1642
|
+
|
|
1643
|
+
if [ "$(id -u)" -ne 0 ]; then
|
|
1644
|
+
echo "Run with sudo: sudo bash .openclaw/secret-runner/install.sh" >&2
|
|
1645
|
+
exit 1
|
|
1646
|
+
fi
|
|
1647
|
+
|
|
1648
|
+
if ! id "$SERVICE_USER" >/dev/null 2>&1; then
|
|
1649
|
+
if command -v useradd >/dev/null 2>&1; then
|
|
1650
|
+
useradd --system --create-home --home-dir "$RUNTIME_DIR" --shell /usr/sbin/nologin "$SERVICE_USER"
|
|
1651
|
+
elif command -v dscl >/dev/null 2>&1; then
|
|
1652
|
+
echo "macOS service-user creation is not automated by this script. Create $SERVICE_USER manually or use launchd/keychain." >&2
|
|
1653
|
+
exit 1
|
|
1654
|
+
else
|
|
1655
|
+
echo "No supported user creation tool found." >&2
|
|
1656
|
+
exit 1
|
|
1657
|
+
fi
|
|
1658
|
+
fi
|
|
1659
|
+
|
|
1660
|
+
install -d -m 0750 -o "$SERVICE_USER" -g "$SERVICE_USER" "$RUNTIME_DIR"
|
|
1661
|
+
install -d -m 0750 -o "$SERVICE_USER" -g "$SERVICE_USER" "$RUNTIME_DIR/keys"
|
|
1662
|
+
install -d -m 0775 -o "$AGENT_USER" -g "$SERVICE_USER" "$WORKSPACE/data/openclaw-growth-engineer" "$WORKSPACE/.openclaw"
|
|
1663
|
+
chmod g+rwX "$WORKSPACE/data/openclaw-growth-engineer" "$WORKSPACE/.openclaw"
|
|
1664
|
+
|
|
1665
|
+
if [ ! -f "$SECRETS_FILE" ]; then
|
|
1666
|
+
install -m 0600 -o "$SERVICE_USER" -g "$SERVICE_USER" /dev/null "$SECRETS_FILE"
|
|
1667
|
+
fi
|
|
1668
|
+
|
|
1669
|
+
if [ -s "$LOCAL_SECRETS_FILE" ] && [ ! -s "$SECRETS_FILE" ]; then
|
|
1670
|
+
cp "$LOCAL_SECRETS_FILE" "$SECRETS_FILE"
|
|
1671
|
+
chown "$SERVICE_USER:$SERVICE_USER" "$SECRETS_FILE"
|
|
1672
|
+
chmod 0600 "$SECRETS_FILE"
|
|
1673
|
+
echo "Migrated existing local secrets into $SECRETS_FILE."
|
|
1674
|
+
echo "After verifying the isolated runner, delete the old local file if OpenClaw runs as that same user:"
|
|
1675
|
+
echo " rm -f $LOCAL_SECRETS_FILE"
|
|
1676
|
+
fi
|
|
1677
|
+
|
|
1678
|
+
cat >/usr/local/bin/openclaw-growth-health <<'EOF'
|
|
1679
|
+
#!/usr/bin/env bash
|
|
1680
|
+
set -euo pipefail
|
|
1681
|
+
WORKSPACE="\${OPENCLAW_GROWTH_WORKSPACE:-__WORKSPACE__}"
|
|
1682
|
+
CONFIG_PATH="\${OPENCLAW_GROWTH_CONFIG_PATH:-__CONFIG_PATH__}"
|
|
1683
|
+
cd "$WORKSPACE"
|
|
1684
|
+
export OPENCLAW_GROWTH_SECRETS_FILE="\${OPENCLAW_GROWTH_SECRETS_FILE:-/var/lib/openclaw-growth/secrets.env}"
|
|
1685
|
+
exec node scripts/openclaw-growth-status.mjs --config "$CONFIG_PATH" --timeout-ms "\${OPENCLAW_GROWTH_STATUS_TIMEOUT_MS:-15000}" --json "$@"
|
|
1686
|
+
EOF
|
|
1687
|
+
sed -i.bak "s#__WORKSPACE__#$WORKSPACE#g; s#__CONFIG_PATH__#$CONFIG_PATH#g" /usr/local/bin/openclaw-growth-health
|
|
1688
|
+
rm -f /usr/local/bin/openclaw-growth-health.bak
|
|
1689
|
+
|
|
1690
|
+
cat >/usr/local/bin/openclaw-growth-run <<'EOF'
|
|
1691
|
+
#!/usr/bin/env bash
|
|
1692
|
+
set -euo pipefail
|
|
1693
|
+
WORKSPACE="\${OPENCLAW_GROWTH_WORKSPACE:-__WORKSPACE__}"
|
|
1694
|
+
CONFIG_PATH="\${OPENCLAW_GROWTH_CONFIG_PATH:-__CONFIG_PATH__}"
|
|
1695
|
+
STATE_PATH="\${OPENCLAW_GROWTH_STATE_PATH:-data/openclaw-growth-engineer/state.json}"
|
|
1696
|
+
cd "$WORKSPACE"
|
|
1697
|
+
export OPENCLAW_GROWTH_SECRETS_FILE="\${OPENCLAW_GROWTH_SECRETS_FILE:-/var/lib/openclaw-growth/secrets.env}"
|
|
1698
|
+
exec node scripts/openclaw-growth-runner.mjs --config "$CONFIG_PATH" --state "$STATE_PATH" "$@"
|
|
1699
|
+
EOF
|
|
1700
|
+
sed -i.bak "s#__WORKSPACE__#$WORKSPACE#g; s#__CONFIG_PATH__#$CONFIG_PATH#g" /usr/local/bin/openclaw-growth-run
|
|
1701
|
+
rm -f /usr/local/bin/openclaw-growth-run.bak
|
|
1702
|
+
|
|
1703
|
+
chown root:root /usr/local/bin/openclaw-growth-health /usr/local/bin/openclaw-growth-run
|
|
1704
|
+
chmod 0755 /usr/local/bin/openclaw-growth-health /usr/local/bin/openclaw-growth-run
|
|
1705
|
+
|
|
1706
|
+
cat >"$SUDOERS_FILE" <<EOF
|
|
1707
|
+
# OpenClaw Growth isolated secret runner.
|
|
1708
|
+
# Allows the agent user to run only the sanitized Growth Engineer wrappers as the secret-owning service user.
|
|
1709
|
+
$AGENT_USER ALL=($SERVICE_USER) NOPASSWD: /usr/local/bin/openclaw-growth-health
|
|
1710
|
+
$AGENT_USER ALL=($SERVICE_USER) NOPASSWD: /usr/local/bin/openclaw-growth-run
|
|
1711
|
+
EOF
|
|
1712
|
+
chmod 0440 "$SUDOERS_FILE"
|
|
1713
|
+
if command -v visudo >/dev/null 2>&1; then
|
|
1714
|
+
visudo -cf "$SUDOERS_FILE"
|
|
1715
|
+
fi
|
|
1716
|
+
|
|
1717
|
+
echo "Installed isolated OpenClaw Growth secret runner."
|
|
1718
|
+
echo "Persisted secret file: $SECRETS_FILE"
|
|
1719
|
+
echo "Edit secrets as root/service operator only:"
|
|
1720
|
+
echo " sudoedit $SECRETS_FILE"
|
|
1721
|
+
echo "OpenClaw may run:"
|
|
1722
|
+
echo " sudo -n -u $SERVICE_USER /usr/local/bin/openclaw-growth-health"
|
|
1723
|
+
echo " sudo -n -u $SERVICE_USER /usr/local/bin/openclaw-growth-run"
|
|
1724
|
+
`;
|
|
1725
|
+
}
|
|
1726
|
+
async function writeIsolatedSecretRunnerKit(configPath, config, options = {}) {
|
|
1727
|
+
const serviceUser = String(options.serviceUser || config?.security?.connectorSecrets?.serviceUser || 'openclaw-growth');
|
|
1728
|
+
const agentUser = String(options.agentUser ||
|
|
1729
|
+
config?.security?.connectorSecrets?.agentUser ||
|
|
1730
|
+
process.env.SUDO_USER ||
|
|
1731
|
+
process.env.USER ||
|
|
1732
|
+
'openclaw');
|
|
1733
|
+
const kitDir = path.resolve('.openclaw/secret-runner');
|
|
1734
|
+
const installScriptPath = path.join(kitDir, 'install.sh');
|
|
1735
|
+
const readmePath = path.join(kitDir, 'README.md');
|
|
1736
|
+
await fs.mkdir(kitDir, { recursive: true });
|
|
1737
|
+
await fs.writeFile(installScriptPath, renderIsolatedSecretRunnerInstallScript({
|
|
1738
|
+
workspaceRoot: process.cwd(),
|
|
1739
|
+
configPath,
|
|
1740
|
+
serviceUser,
|
|
1741
|
+
agentUser,
|
|
1742
|
+
}), { encoding: 'utf8', mode: 0o700 });
|
|
1743
|
+
await fs.chmod(installScriptPath, 0o700);
|
|
1744
|
+
await fs.writeFile(readmePath, [
|
|
1745
|
+
'# OpenClaw Growth Isolated Secret Runner',
|
|
1746
|
+
'',
|
|
1747
|
+
'This kit keeps connector API keys out of the OpenClaw-readable workspace.',
|
|
1748
|
+
'',
|
|
1749
|
+
'1. Run `sudo bash .openclaw/secret-runner/install.sh` from this workspace.',
|
|
1750
|
+
'2. Put connector secrets in `/var/lib/openclaw-growth/secrets.env` with `sudoedit`.',
|
|
1751
|
+
'3. Configure OpenClaw/heartbeat jobs to use the generated sudo commands.',
|
|
1752
|
+
'',
|
|
1753
|
+
'OpenClaw can read and modify non-secret connector config, but must not read or write API keys.',
|
|
1754
|
+
'',
|
|
1755
|
+
].join('\n'), 'utf8');
|
|
1756
|
+
config.security = {
|
|
1757
|
+
...(config.security || {}),
|
|
1758
|
+
connectorSecrets: {
|
|
1759
|
+
mode: 'isolated-runner',
|
|
1760
|
+
persisted: true,
|
|
1761
|
+
agentReadable: false,
|
|
1762
|
+
serviceUser,
|
|
1763
|
+
agentUser,
|
|
1764
|
+
secretsFile: '/var/lib/openclaw-growth/secrets.env',
|
|
1765
|
+
installScript: path.relative(process.cwd(), installScriptPath),
|
|
1766
|
+
healthCommand: `sudo -n -u ${serviceUser} /usr/local/bin/openclaw-growth-health`,
|
|
1767
|
+
runCommand: `sudo -n -u ${serviceUser} /usr/local/bin/openclaw-growth-run`,
|
|
1768
|
+
},
|
|
1769
|
+
};
|
|
1770
|
+
return { installScriptPath, readmePath, serviceUser };
|
|
1771
|
+
}
|
|
1772
|
+
async function askSecretAccessModel(rl, configPath, config) {
|
|
1773
|
+
if (!ENABLE_ISOLATED_SECRET_RUNNER_WIZARD) {
|
|
1774
|
+
config.security = {
|
|
1775
|
+
...(config.security || {}),
|
|
1776
|
+
connectorSecrets: {
|
|
1777
|
+
...(config.security?.connectorSecrets || {}),
|
|
1778
|
+
mode: 'openclaw-secret-refs',
|
|
1779
|
+
persisted: true,
|
|
1780
|
+
agentReadable: 'runtime_resolves_secret_refs',
|
|
1781
|
+
secretsFile: resolveSecretsFile(),
|
|
1782
|
+
},
|
|
1783
|
+
};
|
|
1784
|
+
return { config, kit: null };
|
|
1785
|
+
}
|
|
1786
|
+
process.stdout.write('\nSecret access model\n');
|
|
1787
|
+
process.stdout.write(' 1) Local user secrets file: simplest, same OS user can read it\n');
|
|
1788
|
+
process.stdout.write(' 2) Isolated secret runner: separate service user owns persisted secrets; OpenClaw only gets allowlisted run/health commands\n');
|
|
1789
|
+
const currentMode = config?.security?.connectorSecrets?.mode === 'isolated-runner' ? '2' : '1';
|
|
1790
|
+
const answer = await ask(rl, 'Secret access model (1/2)', currentMode);
|
|
1791
|
+
if (answer.trim() !== '2') {
|
|
1792
|
+
config.security = {
|
|
1793
|
+
...(config.security || {}),
|
|
1794
|
+
connectorSecrets: {
|
|
1795
|
+
...(config.security?.connectorSecrets || {}),
|
|
1796
|
+
mode: 'local-user-file',
|
|
1797
|
+
persisted: true,
|
|
1798
|
+
agentReadable: 'same-os-user-can-read',
|
|
1799
|
+
secretsFile: resolveSecretsFile(),
|
|
1800
|
+
},
|
|
1801
|
+
};
|
|
1802
|
+
return { config, kit: null };
|
|
1803
|
+
}
|
|
1804
|
+
const serviceUser = await ask(rl, 'Service user that owns connector secrets', config?.security?.connectorSecrets?.serviceUser || 'openclaw-growth');
|
|
1805
|
+
const agentUser = await ask(rl, 'Agent OS user allowed to run health/growth commands', config?.security?.connectorSecrets?.agentUser || process.env.SUDO_USER || process.env.USER || 'openclaw');
|
|
1806
|
+
const kit = await writeIsolatedSecretRunnerKit(configPath, config, { serviceUser, agentUser });
|
|
1807
|
+
return { config, kit };
|
|
1808
|
+
}
|
|
1809
|
+
function printSecretRunnerKitInstructions(kit) {
|
|
1810
|
+
if (!kit)
|
|
1811
|
+
return;
|
|
1812
|
+
process.stdout.write(`Saved isolated secret runner setup: ${kit.installScriptPath}\n`);
|
|
1813
|
+
process.stdout.write('Run once from this workspace after the wizard finishes:\n');
|
|
1814
|
+
process.stdout.write(` sudo bash ${path.relative(process.cwd(), kit.installScriptPath)}\n`);
|
|
1815
|
+
process.stdout.write('Then move/persist connector secrets under /var/lib/openclaw-growth/secrets.env with sudoedit.\n');
|
|
1816
|
+
}
|
|
1817
|
+
function getGrowthRunCommand(config, displayConfigPath) {
|
|
1818
|
+
if (config?.security?.connectorSecrets?.mode === 'isolated-runner' && config.security.connectorSecrets.runCommand) {
|
|
1819
|
+
return config.security.connectorSecrets.runCommand;
|
|
1820
|
+
}
|
|
1821
|
+
return `node scripts/openclaw-growth-runner.mjs --config ${displayConfigPath}`;
|
|
1822
|
+
}
|
|
1823
|
+
function getConnectorHealthCommand(config, displayConfigPath) {
|
|
1824
|
+
if (config?.security?.connectorSecrets?.mode === 'isolated-runner' && config.security.connectorSecrets.healthCommand) {
|
|
1825
|
+
return config.security.connectorSecrets.healthCommand;
|
|
1826
|
+
}
|
|
1827
|
+
return `node scripts/openclaw-growth-runner.mjs --config ${displayConfigPath}`;
|
|
1828
|
+
}
|
|
1829
|
+
async function maybePromptSecret(rl, label, envName) {
|
|
1830
|
+
const existing = process.env[envName]?.trim();
|
|
1831
|
+
const suffix = existing ? 'already set in current environment; press Enter to keep' : 'leave empty to skip';
|
|
1832
|
+
const value = await ask(rl, `${label} (${suffix})`, '');
|
|
1833
|
+
const trimmed = value.trim();
|
|
1834
|
+
if (trimmed)
|
|
1835
|
+
return trimmed;
|
|
1836
|
+
if (existing) {
|
|
1837
|
+
process.stdout.write(`Keeping existing ${envName} from the local environment.\n`);
|
|
1838
|
+
return existing;
|
|
1839
|
+
}
|
|
1840
|
+
return '';
|
|
1841
|
+
}
|
|
1842
|
+
function defaultSentryTokenEnv({ index, label, baseUrl }) {
|
|
1843
|
+
const value = `${label || ''} ${baseUrl || ''}`.toLowerCase();
|
|
1844
|
+
if (index === 0 && !value.includes('glitchtip'))
|
|
1845
|
+
return 'SENTRY_AUTH_TOKEN';
|
|
1846
|
+
if (value.includes('glitchtip'))
|
|
1847
|
+
return 'GLITCHTIP_AUTH_TOKEN';
|
|
1848
|
+
return `${toEnvName(label || `SENTRY_${index + 1}`, `SENTRY_${index + 1}`)}_AUTH_TOKEN`;
|
|
1849
|
+
}
|
|
1850
|
+
function defaultSentryAccountLabel({ index, baseUrl }) {
|
|
1851
|
+
const value = String(baseUrl || '').toLowerCase();
|
|
1852
|
+
if (value.includes('glitchtip'))
|
|
1853
|
+
return 'GlitchTip';
|
|
1854
|
+
if (index === 0)
|
|
1855
|
+
return 'Sentry Cloud';
|
|
1856
|
+
return `Sentry Account ${index + 1}`;
|
|
1857
|
+
}
|
|
1858
|
+
function isSentryCloudBaseUrl(baseUrl) {
|
|
1859
|
+
const normalized = String(baseUrl || '').trim().replace(/\/$/, '').toLowerCase();
|
|
1860
|
+
return normalized === 'https://sentry.io' || normalized === 'https://www.sentry.io';
|
|
1861
|
+
}
|
|
1862
|
+
function printSentryTokenGuidance({ baseUrl, tokenEnv }) {
|
|
1863
|
+
if (isSentryCloudBaseUrl(baseUrl)) {
|
|
1864
|
+
process.stdout.write('\nToken type: use a Sentry personal user/auth token, not an organization integration token.\n');
|
|
1865
|
+
process.stdout.write('Sentry token page: https://sentry.io/settings/account/api/auth-tokens/\n');
|
|
1866
|
+
}
|
|
1867
|
+
else {
|
|
1868
|
+
process.stdout.write('\nToken type: use a GlitchTip/Sentry-compatible user auth token for this host.\n');
|
|
1869
|
+
process.stdout.write('GlitchTip token page: Profile -> Auth Tokens on your GlitchTip instance.\n');
|
|
1870
|
+
}
|
|
1871
|
+
printBullets([
|
|
1872
|
+
`Paste it as ${tokenEnv}.`,
|
|
1873
|
+
'Required scopes: `org:read`, `team:read`, `project:read`, and `event:read`.',
|
|
1874
|
+
'Optional for richer release context: `project:releases`.',
|
|
1875
|
+
]);
|
|
1876
|
+
}
|
|
1877
|
+
function parseCommaList(value) {
|
|
1878
|
+
return String(value || '')
|
|
1879
|
+
.split(',')
|
|
1880
|
+
.map((entry) => entry.trim())
|
|
1881
|
+
.filter(Boolean);
|
|
1882
|
+
}
|
|
1883
|
+
function buildUrl(baseUrl, pathname, params = {}) {
|
|
1884
|
+
const url = new URL(pathname, `${String(baseUrl || 'https://sentry.io').replace(/\/$/, '')}/`);
|
|
1885
|
+
for (const [key, value] of Object.entries(params)) {
|
|
1886
|
+
if (value === undefined || value === null || value === '')
|
|
1887
|
+
continue;
|
|
1888
|
+
url.searchParams.set(key, String(value));
|
|
1889
|
+
}
|
|
1890
|
+
return url;
|
|
1891
|
+
}
|
|
1892
|
+
function apiListItems(payload) {
|
|
1893
|
+
if (Array.isArray(payload))
|
|
1894
|
+
return payload;
|
|
1895
|
+
if (!payload || typeof payload !== 'object')
|
|
1896
|
+
return [];
|
|
1897
|
+
if (Array.isArray(payload.results))
|
|
1898
|
+
return payload.results;
|
|
1899
|
+
if (Array.isArray(payload.data))
|
|
1900
|
+
return payload.data;
|
|
1901
|
+
if (Array.isArray(payload.projects))
|
|
1902
|
+
return payload.projects;
|
|
1903
|
+
if (Array.isArray(payload.teams))
|
|
1904
|
+
return payload.teams;
|
|
1905
|
+
return [];
|
|
1906
|
+
}
|
|
1907
|
+
async function fetchSentryJsonPage({ baseUrl, token, url }) {
|
|
1908
|
+
const normalizedToken = String(token || '').trim();
|
|
1909
|
+
const response = await fetch(url, {
|
|
1910
|
+
method: 'GET',
|
|
1911
|
+
headers: {
|
|
1912
|
+
Accept: 'application/json',
|
|
1913
|
+
Authorization: `Bearer ${normalizedToken}`,
|
|
1914
|
+
'User-Agent': 'openclaw-growth-wizard',
|
|
1915
|
+
},
|
|
1916
|
+
});
|
|
1917
|
+
const body = await response.text();
|
|
1918
|
+
if (!response.ok) {
|
|
1919
|
+
return {
|
|
1920
|
+
ok: false,
|
|
1921
|
+
payload: null,
|
|
1922
|
+
detail: `${url.pathname}: HTTP ${response.status}: ${truncate(body, 220)}`,
|
|
1923
|
+
};
|
|
1924
|
+
}
|
|
1925
|
+
try {
|
|
1926
|
+
return { ok: true, payload: body ? JSON.parse(body) : null, detail: url.pathname };
|
|
1927
|
+
}
|
|
1928
|
+
catch (error) {
|
|
1929
|
+
return {
|
|
1930
|
+
ok: false,
|
|
1931
|
+
payload: null,
|
|
1932
|
+
detail: `${url.pathname}: invalid JSON (${error instanceof Error ? error.message : String(error)})`,
|
|
1933
|
+
};
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
async function fetchSentryJsonList({ baseUrl, token, url }) {
|
|
1937
|
+
const items = [];
|
|
1938
|
+
const pages = [];
|
|
1939
|
+
let nextUrl = url;
|
|
1940
|
+
for (let page = 0; nextUrl && page < 10; page += 1) {
|
|
1941
|
+
const result = await fetchSentryJsonPage({ baseUrl, token, url: nextUrl });
|
|
1942
|
+
pages.push(result.detail);
|
|
1943
|
+
if (!result.ok)
|
|
1944
|
+
return { ...result, payload: items, detail: pages.join('; ') };
|
|
1945
|
+
items.push(...apiListItems(result.payload));
|
|
1946
|
+
const next = result.payload && typeof result.payload === 'object' ? result.payload.next : null;
|
|
1947
|
+
nextUrl = typeof next === 'string' && next.trim() ? new URL(next, `${String(baseUrl || 'https://sentry.io').replace(/\/$/, '')}/`) : null;
|
|
1948
|
+
}
|
|
1949
|
+
return { ok: true, payload: items, detail: pages.join('; ') };
|
|
1950
|
+
}
|
|
1951
|
+
async function discoverSentryOrganizations({ baseUrl, token }) {
|
|
1952
|
+
const normalizedToken = String(token || '').trim();
|
|
1953
|
+
if (!normalizedToken)
|
|
1954
|
+
return { ok: false, organizations: [], detail: 'missing token' };
|
|
1955
|
+
const url = buildUrl(baseUrl, '/api/0/organizations/', { per_page: 100 });
|
|
1956
|
+
const result = await fetchSentryJsonList({ baseUrl, token: normalizedToken, url });
|
|
1957
|
+
if (!result.ok)
|
|
1958
|
+
return { ok: false, organizations: [], detail: result.detail };
|
|
1959
|
+
const organizations = apiListItems(result.payload)
|
|
1960
|
+
.map((organization) => ({
|
|
1961
|
+
slug: String(organization?.slug || organization?.name || '').trim(),
|
|
1962
|
+
name: String(organization?.name || organization?.slug || '').trim(),
|
|
1963
|
+
}))
|
|
1964
|
+
.filter((organization) => organization.slug);
|
|
1965
|
+
return {
|
|
1966
|
+
ok: true,
|
|
1967
|
+
organizations: Array.from(new Map(organizations.map((organization) => [organization.slug, organization])).values()),
|
|
1968
|
+
detail: `found ${organizations.length} org(s)`,
|
|
1969
|
+
};
|
|
1970
|
+
}
|
|
1971
|
+
async function discoverSentryProjects({ baseUrl, token, org }) {
|
|
1972
|
+
const normalizedOrg = String(org || '').trim();
|
|
1973
|
+
const normalizedToken = String(token || '').trim();
|
|
1974
|
+
if (!normalizedOrg || !normalizedToken) {
|
|
1975
|
+
return { ok: false, projects: [], detail: 'missing org or token' };
|
|
1976
|
+
}
|
|
1977
|
+
const projectSlugs = (payload) => apiListItems(payload)
|
|
1978
|
+
.map((project) => String(project?.slug || project?.name || '').trim())
|
|
1979
|
+
.filter(Boolean);
|
|
1980
|
+
const attempted = [];
|
|
1981
|
+
try {
|
|
1982
|
+
const orgProjectsUrl = buildUrl(baseUrl, `/api/0/organizations/${encodeURIComponent(normalizedOrg)}/projects/`, {
|
|
1983
|
+
per_page: 100,
|
|
1984
|
+
});
|
|
1985
|
+
const orgProjects = await fetchSentryJsonList({ baseUrl, token: normalizedToken, url: orgProjectsUrl });
|
|
1986
|
+
attempted.push(orgProjects.detail);
|
|
1987
|
+
if (orgProjects.ok) {
|
|
1988
|
+
const projects = projectSlugs(orgProjects.payload);
|
|
1989
|
+
if (projects.length > 0) {
|
|
1990
|
+
return { ok: true, projects: [...new Set(projects)], detail: `found ${projects.length} project(s)` };
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
const teamsUrl = buildUrl(baseUrl, `/api/0/organizations/${encodeURIComponent(normalizedOrg)}/teams/`, {
|
|
1994
|
+
per_page: 100,
|
|
1995
|
+
});
|
|
1996
|
+
const teams = await fetchSentryJsonList({ baseUrl, token: normalizedToken, url: teamsUrl });
|
|
1997
|
+
attempted.push(teams.detail);
|
|
1998
|
+
if (teams.ok) {
|
|
1999
|
+
const teamSlugs = apiListItems(teams.payload)
|
|
2000
|
+
.map((team) => String(team?.slug || team?.name || '').trim())
|
|
2001
|
+
.filter(Boolean);
|
|
2002
|
+
const allTeamProjects = [];
|
|
2003
|
+
for (const teamSlug of teamSlugs) {
|
|
2004
|
+
const teamProjectsUrl = buildUrl(baseUrl, `/api/0/teams/${encodeURIComponent(normalizedOrg)}/${encodeURIComponent(teamSlug)}/projects/`, { per_page: 100 });
|
|
2005
|
+
const teamProjects = await fetchSentryJsonList({ baseUrl, token: normalizedToken, url: teamProjectsUrl });
|
|
2006
|
+
attempted.push(teamProjects.detail);
|
|
2007
|
+
if (teamProjects.ok)
|
|
2008
|
+
allTeamProjects.push(...projectSlugs(teamProjects.payload));
|
|
2009
|
+
}
|
|
2010
|
+
if (allTeamProjects.length > 0) {
|
|
2011
|
+
return {
|
|
2012
|
+
ok: true,
|
|
2013
|
+
projects: [...new Set(allTeamProjects)],
|
|
2014
|
+
detail: `found ${allTeamProjects.length} project(s) via teams`,
|
|
2015
|
+
};
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
const allProjectsUrl = buildUrl(baseUrl, '/api/0/projects/', { per_page: 100 });
|
|
2019
|
+
const allProjects = await fetchSentryJsonList({ baseUrl, token: normalizedToken, url: allProjectsUrl });
|
|
2020
|
+
attempted.push(allProjects.detail);
|
|
2021
|
+
if (allProjects.ok) {
|
|
2022
|
+
const projects = apiListItems(allProjects.payload)
|
|
2023
|
+
.filter((project) => {
|
|
2024
|
+
const projectOrg = String(project?.organization?.slug || project?.organization?.name || '').trim();
|
|
2025
|
+
return !projectOrg || projectOrg === normalizedOrg;
|
|
2026
|
+
})
|
|
2027
|
+
.map((project) => String(project?.slug || project?.name || '').trim())
|
|
2028
|
+
.filter(Boolean);
|
|
2029
|
+
if (projects.length > 0) {
|
|
2030
|
+
return { ok: true, projects: [...new Set(projects)], detail: `found ${projects.length} project(s)` };
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
return {
|
|
2034
|
+
ok: false,
|
|
2035
|
+
projects: [],
|
|
2036
|
+
detail: `found 0 project(s); tried ${attempted.filter(Boolean).join('; ')}`,
|
|
2037
|
+
};
|
|
2038
|
+
}
|
|
2039
|
+
catch (error) {
|
|
2040
|
+
return {
|
|
2041
|
+
ok: false,
|
|
2042
|
+
projects: [],
|
|
2043
|
+
detail: `${error instanceof Error ? error.message : String(error)}; tried ${attempted.filter(Boolean).join('; ')}`,
|
|
2044
|
+
};
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
async function upsertSentryAccountsConfig(configPath, accounts) {
|
|
2048
|
+
if (!accounts.length || !(await fileExists(configPath)))
|
|
2049
|
+
return false;
|
|
2050
|
+
const config = await readJsonFile(configPath);
|
|
2051
|
+
const existingAccounts = Array.isArray(config?.sources?.sentry?.accounts)
|
|
2052
|
+
? config.sources.sentry.accounts
|
|
2053
|
+
: [];
|
|
2054
|
+
const merged = new Map();
|
|
2055
|
+
for (const account of existingAccounts) {
|
|
2056
|
+
const id = String(account?.id || account?.key || account?.label || '').trim();
|
|
2057
|
+
if (id)
|
|
2058
|
+
merged.set(id, account);
|
|
2059
|
+
}
|
|
2060
|
+
for (const account of accounts) {
|
|
2061
|
+
merged.set(account.id, {
|
|
2062
|
+
...(merged.get(account.id) || {}),
|
|
2063
|
+
...account,
|
|
2064
|
+
});
|
|
2065
|
+
}
|
|
2066
|
+
config.sources = {
|
|
2067
|
+
...(config.sources || {}),
|
|
2068
|
+
sentry: {
|
|
2069
|
+
...(config.sources?.sentry || {}),
|
|
2070
|
+
enabled: true,
|
|
2071
|
+
mode: 'command',
|
|
2072
|
+
command: getDefaultSourceCommand('sentry'),
|
|
2073
|
+
accounts: [...merged.values()],
|
|
2074
|
+
},
|
|
2075
|
+
};
|
|
2076
|
+
await writeJsonFile(configPath, config);
|
|
2077
|
+
return true;
|
|
2078
|
+
}
|
|
2079
|
+
const ASC_PRIVATE_KEY_BEGIN = '-----BEGIN PRIVATE KEY-----';
|
|
2080
|
+
const ASC_PRIVATE_KEY_END = '-----END PRIVATE KEY-----';
|
|
2081
|
+
const BRACKETED_PASTE_START = new RegExp(`${String.fromCharCode(27)}\\[200~`, 'g');
|
|
2082
|
+
const BRACKETED_PASTE_END = new RegExp(`${String.fromCharCode(27)}\\[201~`, 'g');
|
|
2083
|
+
function formatPemBase64(value) {
|
|
2084
|
+
return String(value || '').match(/.{1,64}/g)?.join('\n') || '';
|
|
2085
|
+
}
|
|
2086
|
+
function normalizeAscPrivateKeyContent(value) {
|
|
2087
|
+
const raw = String(value || '')
|
|
2088
|
+
.replace(BRACKETED_PASTE_START, '')
|
|
2089
|
+
.replace(BRACKETED_PASTE_END, '')
|
|
2090
|
+
.replace(/\r\n/g, '\n')
|
|
2091
|
+
.trim();
|
|
2092
|
+
if (!raw) {
|
|
2093
|
+
return { ok: false, value: '', error: 'No private key content pasted.' };
|
|
2094
|
+
}
|
|
2095
|
+
const beginIndex = raw.indexOf(ASC_PRIVATE_KEY_BEGIN);
|
|
2096
|
+
const endIndex = raw.indexOf(ASC_PRIVATE_KEY_END);
|
|
2097
|
+
if (beginIndex < 0 || endIndex < 0 || endIndex <= beginIndex) {
|
|
2098
|
+
if (raw.includes('-----BEGIN PRIVATE KEY') && beginIndex < 0) {
|
|
2099
|
+
return {
|
|
2100
|
+
ok: false,
|
|
2101
|
+
value: '',
|
|
2102
|
+
error: `Malformed .p8 header. The first line must be exactly ${ASC_PRIVATE_KEY_BEGIN}`,
|
|
2103
|
+
};
|
|
2104
|
+
}
|
|
2105
|
+
if (raw.includes('-----END PRIVATE KEY') && endIndex < 0) {
|
|
2106
|
+
return {
|
|
2107
|
+
ok: false,
|
|
2108
|
+
value: '',
|
|
2109
|
+
error: `Malformed .p8 footer. The last line must be exactly ${ASC_PRIVATE_KEY_END}`,
|
|
2110
|
+
};
|
|
2111
|
+
}
|
|
2112
|
+
return {
|
|
2113
|
+
ok: false,
|
|
2114
|
+
value: '',
|
|
2115
|
+
error: `Missing exact .p8 markers. Paste from ${ASC_PRIVATE_KEY_BEGIN} through ${ASC_PRIVATE_KEY_END}.`,
|
|
2116
|
+
};
|
|
2117
|
+
}
|
|
2118
|
+
const body = raw
|
|
2119
|
+
.slice(beginIndex + ASC_PRIVATE_KEY_BEGIN.length, endIndex)
|
|
2120
|
+
.replace(/\s+/g, '');
|
|
2121
|
+
if (!body) {
|
|
2122
|
+
return { ok: false, value: '', error: 'The .p8 key body is empty.' };
|
|
2123
|
+
}
|
|
2124
|
+
if (!/^[A-Za-z0-9+/=]+$/.test(body)) {
|
|
2125
|
+
return {
|
|
2126
|
+
ok: false,
|
|
2127
|
+
value: '',
|
|
2128
|
+
error: 'The .p8 key body contains non-base64 characters. Copy the downloaded AuthKey file content without redactions or extra text.',
|
|
2129
|
+
};
|
|
2130
|
+
}
|
|
2131
|
+
return {
|
|
2132
|
+
ok: true,
|
|
2133
|
+
value: `${ASC_PRIVATE_KEY_BEGIN}\n${formatPemBase64(body)}\n${ASC_PRIVATE_KEY_END}\n`,
|
|
2134
|
+
error: null,
|
|
2135
|
+
};
|
|
2136
|
+
}
|
|
2137
|
+
function validateAscPrivateKeyContent(value) {
|
|
2138
|
+
const normalized = normalizeAscPrivateKeyContent(value);
|
|
2139
|
+
if (!normalized.ok)
|
|
2140
|
+
return normalized;
|
|
2141
|
+
try {
|
|
2142
|
+
createPrivateKey(normalized.value);
|
|
2143
|
+
return normalized;
|
|
2144
|
+
}
|
|
2145
|
+
catch (error) {
|
|
2146
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2147
|
+
return {
|
|
2148
|
+
ok: false,
|
|
2149
|
+
value: '',
|
|
2150
|
+
error: `Invalid .p8 private key content: ${message}. Make sure you copied the downloaded AuthKey_<KEY_ID>.p8 file, including both marker lines, with no truncation.`,
|
|
2151
|
+
};
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
async function askAscPrivateKeyContent(rl) {
|
|
2155
|
+
process.stdout.write('\nPaste the full .p8 file content here. Leave the first line empty if you already saved the .p8 file on this host.\n');
|
|
2156
|
+
process.stdout.write('The wizard validates the pasted key, stores it locally with chmod 600, and only saves ASC_PRIVATE_KEY_PATH.\n');
|
|
2157
|
+
while (true) {
|
|
2158
|
+
const value = await readAscPrivateKeyPaste(rl);
|
|
2159
|
+
if (!value.trim())
|
|
2160
|
+
return '';
|
|
2161
|
+
const validation = validateAscPrivateKeyContent(value);
|
|
2162
|
+
if (validation.ok)
|
|
2163
|
+
return validation.value;
|
|
2164
|
+
process.stdout.write(`${validation.error}\n`);
|
|
2165
|
+
process.stdout.write('The .p8 was not saved. Paste the full file again from BEGIN to END, or leave empty to use a path.\n');
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
async function readAscPrivateKeyPaste(rl) {
|
|
2169
|
+
return await new Promise((resolve, reject) => {
|
|
2170
|
+
let buffer = '';
|
|
2171
|
+
let settled = false;
|
|
2172
|
+
let finishing = false;
|
|
2173
|
+
let lineCount = 0;
|
|
2174
|
+
const previousEncoding = process.stdin.readableEncoding;
|
|
2175
|
+
const cleanup = () => {
|
|
2176
|
+
process.stdin.off('data', onData);
|
|
2177
|
+
process.stdin.off('error', onError);
|
|
2178
|
+
if (previousEncoding)
|
|
2179
|
+
process.stdin.setEncoding(previousEncoding);
|
|
2180
|
+
rl.resume();
|
|
2181
|
+
};
|
|
2182
|
+
const complete = (value) => {
|
|
2183
|
+
settled = true;
|
|
2184
|
+
cleanup();
|
|
2185
|
+
resolve(value ? `${String(value).trim()}\n` : '');
|
|
2186
|
+
};
|
|
2187
|
+
const finish = (value, options = {}) => {
|
|
2188
|
+
if (settled || finishing)
|
|
2189
|
+
return;
|
|
2190
|
+
finishing = true;
|
|
2191
|
+
const drainMs = options.drainMs ?? 0;
|
|
2192
|
+
if (drainMs > 0) {
|
|
2193
|
+
setTimeout(() => complete(value), drainMs);
|
|
2194
|
+
}
|
|
2195
|
+
else {
|
|
2196
|
+
complete(value);
|
|
2197
|
+
}
|
|
2198
|
+
};
|
|
2199
|
+
const onError = (error) => {
|
|
2200
|
+
if (settled || finishing)
|
|
2201
|
+
return;
|
|
2202
|
+
settled = true;
|
|
2203
|
+
cleanup();
|
|
2204
|
+
reject(error);
|
|
2205
|
+
};
|
|
2206
|
+
const onData = (chunk) => {
|
|
2207
|
+
if (finishing)
|
|
2208
|
+
return;
|
|
2209
|
+
buffer += String(chunk);
|
|
2210
|
+
lineCount = buffer.split(/\r?\n/).length;
|
|
2211
|
+
if (/^\s*(?:\r?\n)/.test(buffer)) {
|
|
2212
|
+
finish('');
|
|
2213
|
+
return;
|
|
2214
|
+
}
|
|
2215
|
+
const endMatch = buffer.match(/-----END PRIVATE KEY-+[^\r\n]*(?:\r?\n|$)/);
|
|
2216
|
+
if (endMatch?.index !== undefined) {
|
|
2217
|
+
finish(buffer.slice(0, endMatch.index + endMatch[0].length), { drainMs: 750 });
|
|
2218
|
+
return;
|
|
2219
|
+
}
|
|
2220
|
+
if (lineCount > 80) {
|
|
2221
|
+
process.stdout.write('Paste looks incomplete: no -----END PRIVATE KEY----- line found within 80 lines.\n');
|
|
2222
|
+
finish('');
|
|
2223
|
+
}
|
|
2224
|
+
};
|
|
2225
|
+
rl.pause();
|
|
2226
|
+
process.stdin.setEncoding('utf8');
|
|
2227
|
+
process.stdin.on('data', onData);
|
|
2228
|
+
process.stdin.on('error', onError);
|
|
2229
|
+
process.stdout.write('ASC_PRIVATE_KEY content: ');
|
|
2230
|
+
process.stdin.resume();
|
|
2231
|
+
});
|
|
2232
|
+
}
|
|
2233
|
+
async function validateAscPrivateKeyPath(filePath) {
|
|
2234
|
+
const raw = await fs.readFile(filePath, 'utf8');
|
|
2235
|
+
return validateAscPrivateKeyContent(raw);
|
|
2236
|
+
}
|
|
2237
|
+
async function askAscPrivateKeyPath(rl) {
|
|
2238
|
+
while (true) {
|
|
2239
|
+
const privateKeyPath = await ask(rl, 'ASC_PRIVATE_KEY_PATH (path to AuthKey_XXXX.p8, leave empty to skip)', process.env.ASC_PRIVATE_KEY_PATH || '');
|
|
2240
|
+
const trimmedPath = privateKeyPath.trim();
|
|
2241
|
+
if (!trimmedPath)
|
|
2242
|
+
return '';
|
|
2243
|
+
try {
|
|
2244
|
+
const validation = await validateAscPrivateKeyPath(trimmedPath);
|
|
2245
|
+
if (validation.ok)
|
|
2246
|
+
return trimmedPath;
|
|
2247
|
+
process.stdout.write(`${validation.error}\n`);
|
|
2248
|
+
}
|
|
2249
|
+
catch (error) {
|
|
2250
|
+
process.stdout.write(`Could not read .p8 file: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
2251
|
+
}
|
|
2252
|
+
process.stdout.write('The ASC private key path was not saved. Paste a valid path, or leave empty to skip.\n');
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
function isAscWebAuthAuthenticated(stdout) {
|
|
2256
|
+
try {
|
|
2257
|
+
const payload = JSON.parse(String(stdout || '{}'));
|
|
2258
|
+
return payload?.authenticated === true;
|
|
2259
|
+
}
|
|
2260
|
+
catch {
|
|
2261
|
+
return false;
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
function resolveAscWebAppleId() {
|
|
2265
|
+
return (process.env.ASC_WEB_APPLE_ID?.trim() ||
|
|
2266
|
+
process.env.ASC_APPLE_ID?.trim() ||
|
|
2267
|
+
process.env.APPLE_ID?.trim() ||
|
|
2268
|
+
'');
|
|
2269
|
+
}
|
|
2270
|
+
function ascWebAuthEnv() {
|
|
2271
|
+
return {
|
|
2272
|
+
...process.env,
|
|
2273
|
+
ASC_TIMEOUT: process.env.ASC_TIMEOUT || '90s',
|
|
2274
|
+
ASC_TIMEOUT_SECONDS: process.env.ASC_TIMEOUT_SECONDS || '90',
|
|
2275
|
+
};
|
|
2276
|
+
}
|
|
2277
|
+
async function ensureAscWebAnalyticsAuth(rl = null, secrets = {}) {
|
|
2278
|
+
process.stdout.write('\nChecking ASC web analytics authentication...\n');
|
|
2279
|
+
process.stdout.write('Still working: verifying whether the ASC web session is active.\n');
|
|
2280
|
+
if (!(await commandExists('asc'))) {
|
|
2281
|
+
throw new Error('The asc CLI is not installed yet. Install it with `openclaw start --connectors asc`, then rerun the connector wizard so it can run `asc web auth login`.');
|
|
2282
|
+
}
|
|
2283
|
+
const ascEnv = ascWebAuthEnv();
|
|
2284
|
+
if (!process.env.ASC_TIMEOUT && !process.env.ASC_TIMEOUT_SECONDS) {
|
|
2285
|
+
process.stdout.write('Using ASC_TIMEOUT=90s for ASC web auth because Apple web endpoints can be slow.\n');
|
|
2286
|
+
}
|
|
2287
|
+
const status = await runCommandCapture('asc web auth status --output json', { env: ascEnv });
|
|
2288
|
+
if (status.ok && isAscWebAuthAuthenticated(status.stdout)) {
|
|
2289
|
+
process.stdout.write('ASC web analytics authentication is active.\n');
|
|
2290
|
+
return false;
|
|
2291
|
+
}
|
|
2292
|
+
let appleId = resolveAscWebAppleId();
|
|
2293
|
+
if (!appleId && rl) {
|
|
2294
|
+
appleId = (await ask(rl, 'Apple Account email for ASC web analytics login (ASC_WEB_APPLE_ID)', '')).trim();
|
|
2295
|
+
if (appleId) {
|
|
2296
|
+
secrets.ASC_WEB_APPLE_ID = appleId;
|
|
2297
|
+
await saveSecretsImmediately({ ASC_WEB_APPLE_ID: appleId });
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
if (!appleId) {
|
|
2301
|
+
throw new Error('ASC web analytics login needs an Apple Account email. Rerun the connector wizard and enter ASC_WEB_APPLE_ID.');
|
|
2302
|
+
}
|
|
2303
|
+
let attempts = 0;
|
|
2304
|
+
while (true) {
|
|
2305
|
+
attempts += 1;
|
|
2306
|
+
process.stdout.write(`\nASC web login: ${appleId}\n`);
|
|
2307
|
+
process.stdout.write('The next prompts are from asc. Enter the Apple Account password/2FA there.\n\n');
|
|
2308
|
+
const loginCode = await runInteractiveProcess('asc', ['web', 'auth', 'login', '--apple-id', appleId], {
|
|
2309
|
+
env: ascEnv,
|
|
2310
|
+
rl,
|
|
2311
|
+
});
|
|
2312
|
+
if (loginCode === 0) {
|
|
2313
|
+
break;
|
|
2314
|
+
}
|
|
2315
|
+
process.stdout.write('\nASC web login failed.\n');
|
|
2316
|
+
process.stdout.write('Reason: asc/Apple rejected the Apple Account login. The .p8 API key is not used here.\n\n');
|
|
2317
|
+
if (!rl || attempts >= 3) {
|
|
2318
|
+
throw new Error('ASC web analytics login failed. Check the Apple Account email/password/2FA, then rerun the connector wizard.');
|
|
2319
|
+
}
|
|
2320
|
+
const retry = await askYesNo(rl, 'Retry ASC web analytics login now?', true);
|
|
2321
|
+
if (!retry) {
|
|
2322
|
+
throw new Error('ASC web analytics login was not completed. Rerun the connector wizard when the Apple Account login is ready.');
|
|
2323
|
+
}
|
|
2324
|
+
const nextAppleId = (await ask(rl, 'Apple Account email for ASC web analytics login (press Enter to keep)', appleId)).trim();
|
|
2325
|
+
if (nextAppleId && nextAppleId !== appleId) {
|
|
2326
|
+
appleId = nextAppleId;
|
|
2327
|
+
secrets.ASC_WEB_APPLE_ID = appleId;
|
|
2328
|
+
await saveSecretsImmediately({ ASC_WEB_APPLE_ID: appleId });
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
process.stdout.write('\nStill working: verifying the ASC web analytics session after login...\n');
|
|
2332
|
+
const verify = await runCommandCapture('asc web auth status --output json', { env: ascEnv });
|
|
2333
|
+
if (!verify.ok || !isAscWebAuthAuthenticated(verify.stdout)) {
|
|
2334
|
+
throw new Error('ASC web analytics login did not verify. Run `asc web auth status --output json --pretty` to inspect the session, then rerun the connector wizard.');
|
|
2335
|
+
}
|
|
2336
|
+
process.stdout.write('ASC web analytics authentication verified.\n');
|
|
2337
|
+
return true;
|
|
2338
|
+
}
|
|
2339
|
+
function printSection(title, lines = []) {
|
|
2340
|
+
process.stdout.write(`\n${ANSI.bold}${title}${ANSI.reset}\n`);
|
|
2341
|
+
process.stdout.write(`${'-'.repeat(title.length)}\n`);
|
|
2342
|
+
for (const line of lines) {
|
|
2343
|
+
process.stdout.write(`${line}\n`);
|
|
2344
|
+
}
|
|
2345
|
+
if (lines.length > 0)
|
|
2346
|
+
process.stdout.write('\n');
|
|
2347
|
+
}
|
|
2348
|
+
function printBullets(lines) {
|
|
2349
|
+
for (const line of lines) {
|
|
2350
|
+
process.stdout.write(` - ${line}\n`);
|
|
2351
|
+
}
|
|
2352
|
+
process.stdout.write('\n');
|
|
2353
|
+
}
|
|
2354
|
+
async function guideGitHubConnector(rl, secrets) {
|
|
2355
|
+
printSection('GitHub code access', [
|
|
2356
|
+
'Use this when OpenClaw should read repo context or create GitHub delivery artifacts.',
|
|
2357
|
+
]);
|
|
2358
|
+
printBullets([
|
|
2359
|
+
'Open the token page, select the scopes you want, then paste the token here.',
|
|
2360
|
+
'You can rerun this wizard later to change GitHub permissions.',
|
|
2361
|
+
]);
|
|
2362
|
+
let hasGh = await commandExists('gh');
|
|
2363
|
+
if (!hasGh) {
|
|
2364
|
+
hasGh = await installGitHubCliUserLocal();
|
|
2365
|
+
}
|
|
2366
|
+
if (hasGh) {
|
|
2367
|
+
process.stdout.write('GitHub CLI is available for helper commands.\n\n');
|
|
2368
|
+
}
|
|
2369
|
+
process.stdout.write('Token URL: https://github.com/settings/tokens/new\n\n');
|
|
2370
|
+
process.stdout.write(`${ANSI.bold}Suggested scopes${ANSI.reset}\n`);
|
|
2371
|
+
printBullets([
|
|
2372
|
+
'Public repo only: select `public_repo`.',
|
|
2373
|
+
'Private repo access: select `repo` (classic GitHub tokens make private repo access broad).',
|
|
2374
|
+
'Create issues / draft PRs in private repos: `repo` is the relevant classic-token scope.',
|
|
2375
|
+
'Edit GitHub Actions workflow files: add `workflow` only if you explicitly want this.',
|
|
2376
|
+
'Usually do not select: packages, admin:org, hooks, gist, user, delete_repo, enterprise, codespace, copilot.',
|
|
2377
|
+
]);
|
|
2378
|
+
const token = await maybePromptSecret(rl, 'Paste GITHUB_TOKEN into this local terminal', 'GITHUB_TOKEN');
|
|
2379
|
+
if (token)
|
|
2380
|
+
secrets.GITHUB_TOKEN = token;
|
|
2381
|
+
else
|
|
2382
|
+
process.stdout.write('No GitHub token saved. GitHub setup remains pending; rerun this wizard when ready.\n\n');
|
|
2383
|
+
const detectedRepo = await detectGitHubRepo();
|
|
2384
|
+
if (detectedRepo) {
|
|
2385
|
+
secrets.OPENCLAW_GITHUB_REPO = detectedRepo;
|
|
2386
|
+
process.stdout.write(`Detected GitHub repo for this workspace: ${detectedRepo}\n\n`);
|
|
2387
|
+
}
|
|
2388
|
+
else if (token || process.env.GITHUB_TOKEN) {
|
|
2389
|
+
process.stdout.write('GitHub auth is saved. Repo selection is deferred per app/task; no global repo is required.\n\n');
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
function shouldForceFreshAnalyticsToken(healthByConnector = {}) {
|
|
2393
|
+
const health = getConnectorHealth('analytics', healthByConnector);
|
|
2394
|
+
const detail = String(health?.detail || '');
|
|
2395
|
+
return ['blocked', 'partial'].includes(String(health?.status || '')) || /revoked|unauthorized|invalid token/i.test(detail);
|
|
2396
|
+
}
|
|
2397
|
+
async function guideAnalyticsConnector(rl, secrets, options = {}) {
|
|
2398
|
+
printSection('AnalyticsCLI');
|
|
2399
|
+
process.stdout.write('Create a readonly CLI token:\n');
|
|
2400
|
+
process.stdout.write('1. Open https://dash.analyticscli.com/\n');
|
|
2401
|
+
process.stdout.write('2. Account -> API Keys\n');
|
|
2402
|
+
process.stdout.write('3. Create Access Token\n');
|
|
2403
|
+
process.stdout.write('4. Copy the Readonly CLI Token and paste it below\n\n');
|
|
2404
|
+
const forceFresh = Boolean(options.forceFresh);
|
|
2405
|
+
if (forceFresh && process.env.ANALYTICSCLI_ACCESS_TOKEN) {
|
|
2406
|
+
process.stdout.write('Stored token failed. Paste a new token.\n\n');
|
|
2407
|
+
}
|
|
2408
|
+
const token = forceFresh
|
|
2409
|
+
? await ask(rl, 'Paste the new AnalyticsCLI readonly CLI token into this local terminal', '')
|
|
2410
|
+
: await maybePromptSecret(rl, 'Paste AnalyticsCLI readonly CLI token into this local terminal', 'ANALYTICSCLI_ACCESS_TOKEN');
|
|
2411
|
+
if (token) {
|
|
2412
|
+
secrets.ANALYTICSCLI_ACCESS_TOKEN = token;
|
|
2413
|
+
secrets.ANALYTICSCLI_READONLY_TOKEN = token;
|
|
2414
|
+
}
|
|
2415
|
+
else
|
|
2416
|
+
process.stdout.write('No AnalyticsCLI token saved. Product analytics setup remains pending; rerun this wizard when ready.\n\n');
|
|
2417
|
+
}
|
|
2418
|
+
async function guideRevenueCatConnector(rl, secrets) {
|
|
2419
|
+
printSection('RevenueCat monetization data', [
|
|
2420
|
+
'Use this when OpenClaw should read subscription, product, entitlement, and revenue context.',
|
|
2421
|
+
]);
|
|
2422
|
+
process.stdout.write('\nCreate a RevenueCat secret API key here:\n https://app.revenuecat.com/\n\n');
|
|
2423
|
+
printBullets([
|
|
2424
|
+
'Select your app.',
|
|
2425
|
+
'In the sidebar, choose "Apps & providers".',
|
|
2426
|
+
'Click "API keys" and generate a new secret API key.',
|
|
2427
|
+
'Name it "analyticscli" and choose API version 2.',
|
|
2428
|
+
'Set Charts metrics permissions to read.',
|
|
2429
|
+
'Set Customer information permissions to read.',
|
|
2430
|
+
'Set Project configuration permissions to read.',
|
|
2431
|
+
]);
|
|
2432
|
+
const apiKey = await maybePromptSecret(rl, 'Paste REVENUECAT_API_KEY into this local terminal', 'REVENUECAT_API_KEY');
|
|
2433
|
+
if (apiKey)
|
|
2434
|
+
secrets.REVENUECAT_API_KEY = apiKey;
|
|
2435
|
+
}
|
|
2436
|
+
async function guideSentryConnector(rl, secrets) {
|
|
2437
|
+
printSection('Sentry / GlitchTip', [
|
|
2438
|
+
'Paste token, org, and base URL. Projects are discovered automatically.',
|
|
2439
|
+
'Use `https://sentry.io` for Sentry Cloud or your GlitchTip/self-hosted base URL.',
|
|
2440
|
+
]);
|
|
2441
|
+
const accounts = [];
|
|
2442
|
+
let index = 0;
|
|
2443
|
+
while (true) {
|
|
2444
|
+
const baseUrl = await ask(rl, `Sentry account ${index + 1} base URL`, index === 0 ? process.env.SENTRY_BASE_URL || 'https://sentry.io' : 'https://sentry.io');
|
|
2445
|
+
const defaultLabel = defaultSentryAccountLabel({ index, baseUrl });
|
|
2446
|
+
const label = await ask(rl, `Sentry account ${index + 1} label`, defaultLabel);
|
|
2447
|
+
const id = toConfigId(label || baseUrl, `sentry_${index + 1}`);
|
|
2448
|
+
const tokenEnv = defaultSentryTokenEnv({ index, label, baseUrl });
|
|
2449
|
+
printSentryTokenGuidance({ baseUrl, tokenEnv });
|
|
2450
|
+
const token = await maybePromptSecret(rl, `Paste ${tokenEnv} into this local terminal`, tokenEnv);
|
|
2451
|
+
if (token)
|
|
2452
|
+
secrets[tokenEnv] = token;
|
|
2453
|
+
let discoveredOrganizations = [];
|
|
2454
|
+
if (token) {
|
|
2455
|
+
process.stdout.write(`Discovering Sentry / GlitchTip organizations for ${label}...\n`);
|
|
2456
|
+
const organizationDiscovery = await discoverSentryOrganizations({ baseUrl, token });
|
|
2457
|
+
if (organizationDiscovery.ok && organizationDiscovery.organizations.length > 0) {
|
|
2458
|
+
discoveredOrganizations = organizationDiscovery.organizations;
|
|
2459
|
+
process.stdout.write(`Found org(s): ${discoveredOrganizations.map((organization) => organization.slug).join(', ')}\n`);
|
|
2460
|
+
}
|
|
2461
|
+
else if (!organizationDiscovery.ok) {
|
|
2462
|
+
process.stdout.write(`${ANSI.dim}Could not list organizations automatically (${organizationDiscovery.detail}).${ANSI.reset}\n`);
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
let org = '';
|
|
2466
|
+
if (discoveredOrganizations.length === 1) {
|
|
2467
|
+
org = discoveredOrganizations[0].slug;
|
|
2468
|
+
process.stdout.write(`Using organization: ${org}\n`);
|
|
2469
|
+
}
|
|
2470
|
+
else if (discoveredOrganizations.length > 1) {
|
|
2471
|
+
process.stdout.write('Select organization:\n');
|
|
2472
|
+
const orgChoice = await askListSelection(rl, `Organization for ${label}`, discoveredOrganizations.map((organization) => ({
|
|
2473
|
+
value: organization.slug,
|
|
2474
|
+
label: organization.slug,
|
|
2475
|
+
description: organization.name && organization.name !== organization.slug ? organization.name : '',
|
|
2476
|
+
})), { includeManual: true, includeDefer: true });
|
|
2477
|
+
org = orgChoice === '__manual__'
|
|
2478
|
+
? await ask(rl, `Sentry org slug for ${label}`, index === 0 ? process.env.SENTRY_ORG || '' : '')
|
|
2479
|
+
: orgChoice;
|
|
2480
|
+
}
|
|
2481
|
+
else {
|
|
2482
|
+
org = await ask(rl, `Sentry org slug for ${label} (leave empty to defer)`, index === 0 ? process.env.SENTRY_ORG || '' : '');
|
|
2483
|
+
}
|
|
2484
|
+
const environment = await ask(rl, `Sentry environment for ${label}`, index === 0 ? process.env.SENTRY_ENVIRONMENT || 'production' : 'production');
|
|
2485
|
+
let projects = [];
|
|
2486
|
+
if (org.trim() && token) {
|
|
2487
|
+
process.stdout.write(`Discovering Sentry projects for ${label}...\n`);
|
|
2488
|
+
const discovery = await discoverSentryProjects({ baseUrl, token, org });
|
|
2489
|
+
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`);
|
|
2492
|
+
}
|
|
2493
|
+
else {
|
|
2494
|
+
const fallbackOrgs = discoveredOrganizations
|
|
2495
|
+
.map((organization) => organization.slug)
|
|
2496
|
+
.filter((slug) => slug && slug !== org.trim());
|
|
2497
|
+
for (const fallbackOrg of fallbackOrgs) {
|
|
2498
|
+
process.stdout.write(`Trying visible org ${fallbackOrg}...\n`);
|
|
2499
|
+
const fallbackDiscovery = await discoverSentryProjects({ baseUrl, token, org: fallbackOrg });
|
|
2500
|
+
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`);
|
|
2503
|
+
break;
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
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;
|
|
2510
|
+
}
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
else {
|
|
2514
|
+
process.stdout.write('Project discovery needs both a token and org slug. Project scope will be resolved from app context later.\n');
|
|
2515
|
+
}
|
|
2516
|
+
accounts.push({
|
|
2517
|
+
id,
|
|
2518
|
+
label,
|
|
2519
|
+
baseUrl,
|
|
2520
|
+
tokenEnv,
|
|
2521
|
+
...(org.trim() ? { org: org.trim() } : {}),
|
|
2522
|
+
...(projects.length > 0 ? { projects } : {}),
|
|
2523
|
+
...(environment.trim() ? { environment: environment.trim() } : {}),
|
|
2524
|
+
});
|
|
2525
|
+
if (index === 0) {
|
|
2526
|
+
if (tokenEnv === 'SENTRY_AUTH_TOKEN' && token)
|
|
2527
|
+
secrets.SENTRY_AUTH_TOKEN = token;
|
|
2528
|
+
if (org.trim())
|
|
2529
|
+
secrets.SENTRY_ORG = org.trim();
|
|
2530
|
+
if (environment.trim())
|
|
2531
|
+
secrets.SENTRY_ENVIRONMENT = environment.trim();
|
|
2532
|
+
if (baseUrl.trim() && baseUrl.trim() !== 'https://sentry.io')
|
|
2533
|
+
secrets.SENTRY_BASE_URL = baseUrl.trim();
|
|
2534
|
+
}
|
|
2535
|
+
const addAnother = await askYesNo(rl, 'Configure another Sentry / GlitchTip account now, for example on another base URL?', false);
|
|
2536
|
+
if (!addAnother)
|
|
2537
|
+
break;
|
|
2538
|
+
index += 1;
|
|
2539
|
+
}
|
|
2540
|
+
return accounts;
|
|
2541
|
+
}
|
|
2542
|
+
async function guideAscConnector(rl, secrets) {
|
|
2543
|
+
printSection('App Store Connect CLI', [
|
|
2544
|
+
'Use this mainly for App Store analytics, plus builds, TestFlight, reviews, ratings, and store context.',
|
|
2545
|
+
'ASC web analytics also needs a website login; this wizard verifies it after helper setup.',
|
|
2546
|
+
]);
|
|
2547
|
+
process.stdout.write('Create an App Store Connect API key here:\n https://appstoreconnect.apple.com/access/integrations/api\n\n');
|
|
2548
|
+
process.stdout.write('Roles to choose for this key:\n');
|
|
2549
|
+
printBullets([
|
|
2550
|
+
'Required: Sales, for App Analytics, Sales and Trends, downloads, revenue, and conversion context.',
|
|
2551
|
+
'Recommended: Customer Support, for App Store ratings and review text.',
|
|
2552
|
+
'Recommended: Developer, for builds, TestFlight, and delivery status.',
|
|
2553
|
+
'Optional: App Manager, only if OpenClaw should also read or manage app metadata, pricing, or release settings.',
|
|
2554
|
+
'Avoid: Admin unless a one-off App Store Connect permission requires it.',
|
|
2555
|
+
]);
|
|
2556
|
+
process.stdout.write('\nAfter creating the key, copy these values into this wizard:\n');
|
|
2557
|
+
printBullets([
|
|
2558
|
+
'Issuer ID from the API keys page.',
|
|
2559
|
+
'Key ID from the API key row or from the downloaded file name: AuthKey_<KEY_ID>.p8.',
|
|
2560
|
+
'Download the .p8 file, open it, then paste the full file content into this terminal.',
|
|
2561
|
+
'If the .p8 is already on this host, leave the content prompt empty and paste the file path instead.',
|
|
2562
|
+
]);
|
|
2563
|
+
const keyId = await ask(rl, 'ASC_KEY_ID (leave empty to skip)', process.env.ASC_KEY_ID || '');
|
|
2564
|
+
const issuerId = await ask(rl, 'ASC_ISSUER_ID (leave empty to skip)', process.env.ASC_ISSUER_ID || '');
|
|
2565
|
+
if (keyId.trim())
|
|
2566
|
+
secrets.ASC_KEY_ID = keyId.trim();
|
|
2567
|
+
if (issuerId.trim())
|
|
2568
|
+
secrets.ASC_ISSUER_ID = issuerId.trim();
|
|
2569
|
+
const appleId = await ask(rl, 'Apple Account email for ASC web analytics login (ASC_WEB_APPLE_ID, leave empty to skip)', resolveAscWebAppleId());
|
|
2570
|
+
if (appleId.trim())
|
|
2571
|
+
secrets.ASC_WEB_APPLE_ID = appleId.trim();
|
|
2572
|
+
process.stdout.write('ASC web password and 2FA are not stored by this wizard; asc asks for them interactively during web login.\n');
|
|
2573
|
+
const privateKeyContent = await askAscPrivateKeyContent(rl);
|
|
2574
|
+
if (privateKeyContent) {
|
|
2575
|
+
const privateKeyPath = resolveAscPrivateKeyPath(keyId);
|
|
2576
|
+
await fs.mkdir(path.dirname(privateKeyPath), { recursive: true, mode: 0o700 });
|
|
2577
|
+
await fs.writeFile(privateKeyPath, privateKeyContent, { encoding: 'utf8', mode: 0o600 });
|
|
2578
|
+
await fs.chmod(privateKeyPath, 0o600);
|
|
2579
|
+
secrets.ASC_PRIVATE_KEY_PATH = privateKeyPath;
|
|
2580
|
+
process.stdout.write(`Saved ASC private key to ${privateKeyPath} with chmod 600.\n`);
|
|
2581
|
+
}
|
|
2582
|
+
else {
|
|
2583
|
+
const privateKeyPath = await askAscPrivateKeyPath(rl);
|
|
2584
|
+
if (privateKeyPath.trim())
|
|
2585
|
+
secrets.ASC_PRIVATE_KEY_PATH = privateKeyPath.trim();
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2588
|
+
async function shouldRunSelfUpdate(workspaceRoot, force) {
|
|
2589
|
+
if (force)
|
|
2590
|
+
return true;
|
|
2591
|
+
const statePath = path.join(workspaceRoot, 'data/openclaw-growth-engineer/self-update.json');
|
|
2592
|
+
const state = await readJsonIfPresent(statePath).catch(() => null);
|
|
2593
|
+
const lastCheckedAt = Date.parse(String(state?.lastCheckedAt || ''));
|
|
2594
|
+
return !Number.isFinite(lastCheckedAt) || Date.now() - lastCheckedAt > SELF_UPDATE_INTERVAL_MS;
|
|
2595
|
+
}
|
|
2596
|
+
async function writeSelfUpdateState(workspaceRoot, value) {
|
|
2597
|
+
const statePath = path.join(workspaceRoot, 'data/openclaw-growth-engineer/self-update.json');
|
|
2598
|
+
await writeJsonFile(statePath, {
|
|
2599
|
+
version: 1,
|
|
2600
|
+
checkedAt: new Date().toISOString(),
|
|
2601
|
+
...value,
|
|
2602
|
+
});
|
|
2603
|
+
}
|
|
2604
|
+
async function rerunCurrentWizardWithoutSelfUpdate() {
|
|
2605
|
+
return await new Promise((resolve) => {
|
|
2606
|
+
const child = spawn(process.execPath, process.argv.slice(1), {
|
|
2607
|
+
env: {
|
|
2608
|
+
...process.env,
|
|
2609
|
+
OPENCLAW_GROWTH_SKIP_SELF_UPDATE: '1',
|
|
2610
|
+
},
|
|
2611
|
+
stdio: 'inherit',
|
|
2612
|
+
});
|
|
2613
|
+
child.on('error', () => resolve(1));
|
|
2614
|
+
child.on('close', (code) => resolve(code));
|
|
2615
|
+
});
|
|
2616
|
+
}
|
|
2617
|
+
async function filesHaveSameContent(leftPath, rightPath) {
|
|
2618
|
+
try {
|
|
2619
|
+
const [left, right] = await Promise.all([fs.readFile(leftPath), fs.readFile(rightPath)]);
|
|
2620
|
+
return left.equals(right);
|
|
2621
|
+
}
|
|
2622
|
+
catch {
|
|
2623
|
+
return false;
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
async function maybeSelfUpdateFromClawHub(args) {
|
|
2627
|
+
if (args.noSelfUpdate)
|
|
2628
|
+
return false;
|
|
2629
|
+
if (isTruthyEnv(process.env.OPENCLAW_GROWTH_SKIP_SELF_UPDATE))
|
|
2630
|
+
return false;
|
|
2631
|
+
if (isTruthyEnv(process.env.OPENCLAW_GROWTH_DISABLE_SELF_UPDATE))
|
|
2632
|
+
return false;
|
|
2633
|
+
if (isFalseyEnv(process.env.OPENCLAW_GROWTH_SELF_UPDATE))
|
|
2634
|
+
return false;
|
|
2635
|
+
const workspaceRoot = process.cwd();
|
|
2636
|
+
const skillOriginPath = path.join(workspaceRoot, 'skills/openclaw-growth-engineer/.clawhub/origin.json');
|
|
2637
|
+
if (!(await fileExists(skillOriginPath)))
|
|
2638
|
+
return false;
|
|
2639
|
+
if (!(await commandExists('npx')))
|
|
2640
|
+
return false;
|
|
2641
|
+
const force = String(process.env.OPENCLAW_GROWTH_SELF_UPDATE || '').trim().toLowerCase() === 'always';
|
|
2642
|
+
if (!(await shouldRunSelfUpdate(workspaceRoot, force)))
|
|
2643
|
+
return false;
|
|
2644
|
+
const beforeOrigin = await readJsonIfPresent(skillOriginPath).catch(() => null);
|
|
2645
|
+
const beforeVersion = String(beforeOrigin?.installedVersion || '');
|
|
2646
|
+
process.stdout.write('Checking for OpenClaw Growth Engineer skill updates...\n');
|
|
2647
|
+
const updateResult = await runCommandCaptureWithTimeout('npx -y clawhub --no-input --dir skills update openclaw-growth-engineer --force', { timeoutMs: 120_000 });
|
|
2648
|
+
const afterOrigin = await readJsonIfPresent(skillOriginPath).catch(() => null);
|
|
2649
|
+
const afterVersion = String(afterOrigin?.installedVersion || beforeVersion || '');
|
|
2650
|
+
const workspaceWizardPath = path.resolve(process.argv[1] || 'scripts/openclaw-growth-wizard.mjs');
|
|
2651
|
+
const skillWizardPath = path.join(workspaceRoot, 'skills/openclaw-growth-engineer/scripts/openclaw-growth-wizard.mjs');
|
|
2652
|
+
const runtimeOutdated = !(await filesHaveSameContent(workspaceWizardPath, skillWizardPath));
|
|
2653
|
+
await writeSelfUpdateState(workspaceRoot, {
|
|
2654
|
+
lastCheckedAt: new Date().toISOString(),
|
|
2655
|
+
ok: updateResult.ok,
|
|
2656
|
+
previousVersion: beforeVersion || null,
|
|
2657
|
+
installedVersion: afterVersion || null,
|
|
2658
|
+
}).catch(() => { });
|
|
2659
|
+
if (!updateResult.ok) {
|
|
2660
|
+
const detail = String(updateResult.stderr || updateResult.stdout || 'update failed').trim().split(/\r?\n/).pop();
|
|
2661
|
+
process.stdout.write(`${ANSI.dim}Skill update check skipped: ${detail}${ANSI.reset}\n`);
|
|
2662
|
+
return false;
|
|
2663
|
+
}
|
|
2664
|
+
if ((!afterVersion || afterVersion === beforeVersion) && !runtimeOutdated) {
|
|
2665
|
+
return false;
|
|
2666
|
+
}
|
|
2667
|
+
if (afterVersion && afterVersion !== beforeVersion) {
|
|
2668
|
+
process.stdout.write(`Updated OpenClaw Growth Engineer skill ${beforeVersion || 'unknown'} -> ${afterVersion}. Refreshing workspace runtime...\n`);
|
|
2669
|
+
}
|
|
2670
|
+
else {
|
|
2671
|
+
process.stdout.write('Refreshing workspace runtime from the installed OpenClaw Growth Engineer skill...\n');
|
|
2672
|
+
}
|
|
2673
|
+
const bootstrapResult = await runCommandCaptureWithTimeout('bash skills/openclaw-growth-engineer/scripts/bootstrap-openclaw-workspace.sh', { timeoutMs: 60_000 });
|
|
2674
|
+
if (!bootstrapResult.ok) {
|
|
2675
|
+
process.stdout.write(`${ANSI.dim}Workspace runtime refresh failed; continuing with current process.${ANSI.reset}\n`);
|
|
2676
|
+
return false;
|
|
2677
|
+
}
|
|
2678
|
+
process.stdout.write('Restarting wizard with refreshed runtime...\n');
|
|
2679
|
+
const code = await rerunCurrentWizardWithoutSelfUpdate();
|
|
2680
|
+
process.exit(code ?? 0);
|
|
2681
|
+
}
|
|
2682
|
+
async function runConnectorSetupWizard(args) {
|
|
2683
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
2684
|
+
throw new Error('Connector wizard requires an interactive terminal.');
|
|
2685
|
+
}
|
|
2686
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
2687
|
+
try {
|
|
2688
|
+
clearTerminal();
|
|
2689
|
+
printConnectorIntro();
|
|
2690
|
+
const healthByConnector = await withConnectorHealthLoading((onProgress) => getConnectorPickerHealth(args.config, onProgress));
|
|
2691
|
+
const existingFixes = connectorKeysNeedingAttention(healthByConnector);
|
|
2692
|
+
const requestedConnectors = args.connectors ? parseConnectorList(args.connectors) : [];
|
|
2693
|
+
const chosenConnectors = requestedConnectors.length > 0
|
|
2694
|
+
? orderConnectors([...new Set([...requestedConnectors, ...existingFixes])])
|
|
2695
|
+
: await askConnectorSelectionWithHealth(rl, healthByConnector, existingFixes);
|
|
2696
|
+
let selected = withMissingRequiredAnalyticsConnector(chosenConnectors);
|
|
2697
|
+
if (selected.length === 0) {
|
|
2698
|
+
throw new Error('No supported connectors selected. Use analytics, github, revenuecat, sentry, asc, or all.');
|
|
2699
|
+
}
|
|
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;
|
|
2835
|
+
}
|
|
2836
|
+
finally {
|
|
2837
|
+
rl.close();
|
|
2838
|
+
}
|
|
2839
|
+
}
|
|
2840
|
+
function clearPromptInput(rl) {
|
|
2841
|
+
try {
|
|
2842
|
+
rl.write?.(null, { ctrl: true, name: 'u' });
|
|
2843
|
+
}
|
|
2844
|
+
catch {
|
|
2845
|
+
// Best-effort cleanup for stale pasted terminal input before showing a prompt.
|
|
2846
|
+
}
|
|
2847
|
+
}
|
|
2848
|
+
async function ask(rl, label, defaultValue = '') {
|
|
2849
|
+
const suffix = defaultValue ? ` (${defaultValue})` : '';
|
|
2850
|
+
clearPromptInput(rl);
|
|
2851
|
+
const answer = (await rl.question(`${label}${suffix}: `)).trim();
|
|
2852
|
+
return answer || defaultValue;
|
|
2853
|
+
}
|
|
2854
|
+
async function askYesNo(rl, label, defaultYes = true) {
|
|
2855
|
+
const suffix = defaultYes ? '[Y/n]' : '[y/N]';
|
|
2856
|
+
while (true) {
|
|
2857
|
+
clearPromptInput(rl);
|
|
2858
|
+
const answer = (await rl.question(`${label} ${suffix} `)).trim().toLowerCase();
|
|
2859
|
+
if (!answer)
|
|
2860
|
+
return defaultYes;
|
|
2861
|
+
if (answer === 'y' || answer === 'yes')
|
|
2862
|
+
return true;
|
|
2863
|
+
if (answer === 'n' || answer === 'no')
|
|
2864
|
+
return false;
|
|
2865
|
+
if (answer.includes('private key')) {
|
|
2866
|
+
process.stdout.write('That looks like leftover .p8 key text, not a yes/no answer. Ignoring it.\n');
|
|
2867
|
+
}
|
|
2868
|
+
else {
|
|
2869
|
+
process.stdout.write('Please answer y or n.\n');
|
|
2870
|
+
}
|
|
2871
|
+
}
|
|
2872
|
+
}
|
|
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
|
+
};
|
|
2914
|
+
}
|
|
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
|
+
};
|
|
2923
|
+
}
|
|
2924
|
+
function printCadencePlan(cadences) {
|
|
2925
|
+
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
|
+
}
|
|
2930
|
+
process.stdout.write('\n');
|
|
2931
|
+
}
|
|
2932
|
+
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 }));
|
|
2946
|
+
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)
|
|
2949
|
+
return cadences;
|
|
2950
|
+
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)
|
|
2955
|
+
continue;
|
|
2956
|
+
cadence.objective = await ask(rl, `${cadence.key} objective`, cadence.objective);
|
|
2957
|
+
cadence.instructions = await ask(rl, `${cadence.key} instructions`, cadence.instructions);
|
|
2958
|
+
const focusAreas = await ask(rl, `${cadence.key} focus areas (comma-separated)`, cadence.focusAreas.join(','));
|
|
2959
|
+
cadence.focusAreas = focusAreas.split(',').map((value) => value.trim()).filter(Boolean);
|
|
2960
|
+
const sourcePriorities = await ask(rl, `${cadence.key} source priorities (comma-separated)`, cadence.sourcePriorities.join(','));
|
|
2961
|
+
cadence.sourcePriorities = sourcePriorities.split(',').map((value) => value.trim()).filter(Boolean);
|
|
2962
|
+
cadence.criticalOnly = await askYesNo(rl, `${cadence.key} should only alert on critical findings?`, cadence.criticalOnly);
|
|
2963
|
+
}
|
|
2964
|
+
return cadences;
|
|
2965
|
+
}
|
|
2966
|
+
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';
|
|
2980
|
+
}
|
|
2981
|
+
async function buildDefaultWizardConfig() {
|
|
2982
|
+
const detectedRepo = await detectGitHubRepo();
|
|
2983
|
+
return {
|
|
2984
|
+
version: 7,
|
|
2985
|
+
generatedAt: new Date().toISOString(),
|
|
2986
|
+
project: {
|
|
2987
|
+
githubRepo: detectedRepo || '',
|
|
2988
|
+
repoRoot: '.',
|
|
2989
|
+
outFile: 'data/openclaw-growth-engineer/issues.generated.json',
|
|
2990
|
+
maxIssues: 4,
|
|
2991
|
+
titlePrefix: '[Growth]',
|
|
2992
|
+
labels: ['ai-growth', 'autogenerated', 'product'],
|
|
2993
|
+
},
|
|
2994
|
+
sources: {
|
|
2995
|
+
analytics: {
|
|
2996
|
+
enabled: true,
|
|
2997
|
+
mode: 'command',
|
|
2998
|
+
command: getDefaultSourceCommand('analytics'),
|
|
2999
|
+
},
|
|
3000
|
+
revenuecat: {
|
|
3001
|
+
enabled: false,
|
|
3002
|
+
mode: 'command',
|
|
3003
|
+
command: getDefaultSourceCommand('revenuecat'),
|
|
3004
|
+
},
|
|
3005
|
+
sentry: {
|
|
3006
|
+
enabled: false,
|
|
3007
|
+
mode: 'command',
|
|
3008
|
+
command: getDefaultSourceCommand('sentry'),
|
|
3009
|
+
},
|
|
3010
|
+
feedback: {
|
|
3011
|
+
enabled: true,
|
|
3012
|
+
mode: 'command',
|
|
3013
|
+
command: getDefaultSourceCommand('feedback'),
|
|
3014
|
+
cursorMode: 'auto_since_last_fetch',
|
|
3015
|
+
initialLookback: '30d',
|
|
3016
|
+
},
|
|
3017
|
+
extra: [
|
|
3018
|
+
buildExtraSourceConfig('asc-cli', { enabled: false, mode: 'command', command: getDefaultSourceCommand('asc') }),
|
|
3019
|
+
],
|
|
3020
|
+
},
|
|
3021
|
+
schedule: {
|
|
3022
|
+
intervalMinutes: 1440,
|
|
3023
|
+
connectorHealthCheckIntervalMinutes: 720,
|
|
3024
|
+
skipIfNoDataChange: true,
|
|
3025
|
+
skipIfIssueSetUnchanged: true,
|
|
3026
|
+
cadences: DEFAULT_CADENCE_PLAN.map((cadence) => ({ ...cadence })),
|
|
3027
|
+
},
|
|
3028
|
+
actions: {
|
|
3029
|
+
autoCreateIssues: false,
|
|
3030
|
+
autoCreatePullRequests: false,
|
|
3031
|
+
mode: 'issue',
|
|
3032
|
+
usageMode: 'production_autopilot',
|
|
3033
|
+
draftPullRequests: true,
|
|
3034
|
+
proposalBranchPrefix: 'openclaw/proposals',
|
|
3035
|
+
},
|
|
3036
|
+
deliveries: {
|
|
3037
|
+
openclawChat: {
|
|
3038
|
+
enabled: true,
|
|
3039
|
+
markdownPath: '.openclaw/chat/latest.md',
|
|
3040
|
+
jsonPath: '.openclaw/chat/latest.json',
|
|
3041
|
+
},
|
|
3042
|
+
github: {
|
|
3043
|
+
enabled: false,
|
|
3044
|
+
mode: 'issue',
|
|
3045
|
+
autoCreate: false,
|
|
3046
|
+
draftPullRequests: true,
|
|
3047
|
+
proposalBranchPrefix: 'openclaw/proposals',
|
|
3048
|
+
},
|
|
3049
|
+
slack: {
|
|
3050
|
+
enabled: false,
|
|
3051
|
+
webhookEnv: 'SLACK_WEBHOOK_URL',
|
|
3052
|
+
},
|
|
3053
|
+
webhook: {
|
|
3054
|
+
enabled: false,
|
|
3055
|
+
urlEnv: 'OPENCLAW_WEBHOOK_URL',
|
|
3056
|
+
method: 'POST',
|
|
3057
|
+
headers: {},
|
|
3058
|
+
},
|
|
3059
|
+
discord: {
|
|
3060
|
+
enabled: false,
|
|
3061
|
+
command: 'node scripts/discord-openclaw-bridge.mjs send --stdin',
|
|
3062
|
+
},
|
|
3063
|
+
},
|
|
3064
|
+
charting: {
|
|
3065
|
+
enabled: false,
|
|
3066
|
+
command: null,
|
|
3067
|
+
},
|
|
3068
|
+
notifications: {
|
|
3069
|
+
connectorHealth: {
|
|
3070
|
+
enabled: true,
|
|
3071
|
+
channels: [
|
|
3072
|
+
{
|
|
3073
|
+
type: 'openclaw-chat',
|
|
3074
|
+
enabled: true,
|
|
3075
|
+
markdownPath: '.openclaw/chat/connector-health.md',
|
|
3076
|
+
jsonPath: '.openclaw/chat/connector-health.json',
|
|
3077
|
+
},
|
|
3078
|
+
],
|
|
3079
|
+
},
|
|
3080
|
+
growthRun: {
|
|
3081
|
+
enabled: true,
|
|
3082
|
+
channels: [
|
|
3083
|
+
{
|
|
3084
|
+
type: 'openclaw-chat',
|
|
3085
|
+
enabled: true,
|
|
3086
|
+
markdownPath: '.openclaw/chat/growth-summary.md',
|
|
3087
|
+
jsonPath: '.openclaw/chat/growth-summary.json',
|
|
3088
|
+
},
|
|
3089
|
+
],
|
|
3090
|
+
},
|
|
3091
|
+
},
|
|
3092
|
+
secrets: {
|
|
3093
|
+
githubTokenEnv: 'GITHUB_TOKEN',
|
|
3094
|
+
githubTokenRef: { source: 'env', provider: 'default', id: 'GITHUB_TOKEN' },
|
|
3095
|
+
analyticsTokenEnv: 'ANALYTICSCLI_ACCESS_TOKEN',
|
|
3096
|
+
analyticsTokenRef: { source: 'env', provider: 'default', id: 'ANALYTICSCLI_ACCESS_TOKEN' },
|
|
3097
|
+
revenuecatTokenEnv: 'REVENUECAT_API_KEY',
|
|
3098
|
+
revenuecatTokenRef: { source: 'env', provider: 'default', id: 'REVENUECAT_API_KEY' },
|
|
3099
|
+
sentryTokenEnv: 'SENTRY_AUTH_TOKEN',
|
|
3100
|
+
sentryTokenRef: { source: 'env', provider: 'default', id: 'SENTRY_AUTH_TOKEN' },
|
|
3101
|
+
},
|
|
3102
|
+
};
|
|
3103
|
+
}
|
|
3104
|
+
async function loadEditableConfig(configPath) {
|
|
3105
|
+
const existing = await readJsonIfPresent(configPath).catch(() => null);
|
|
3106
|
+
if (existing && typeof existing === 'object')
|
|
3107
|
+
return existing;
|
|
3108
|
+
return await buildDefaultWizardConfig();
|
|
3109
|
+
}
|
|
3110
|
+
function mergeNotificationChannels(baseChannels, extraChannels) {
|
|
3111
|
+
const channels = [];
|
|
3112
|
+
const seen = new Set();
|
|
3113
|
+
for (const channel of [...baseChannels, ...extraChannels]) {
|
|
3114
|
+
if (!channel || channel.enabled === false)
|
|
3115
|
+
continue;
|
|
3116
|
+
const key = `${channel.type}:${channel.markdownPath || channel.jsonPath || channel.webhookEnv || channel.urlEnv || channel.command || channel.label || ''}`;
|
|
3117
|
+
if (seen.has(key))
|
|
3118
|
+
continue;
|
|
3119
|
+
seen.add(key);
|
|
3120
|
+
channels.push(channel);
|
|
3121
|
+
}
|
|
3122
|
+
return channels;
|
|
3123
|
+
}
|
|
3124
|
+
async function askNotificationChannels(rl, config) {
|
|
3125
|
+
const channels = [
|
|
3126
|
+
{
|
|
3127
|
+
type: 'openclaw-chat',
|
|
3128
|
+
enabled: true,
|
|
3129
|
+
markdownPath: '.openclaw/chat/growth-summary.md',
|
|
3130
|
+
jsonPath: '.openclaw/chat/growth-summary.json',
|
|
3131
|
+
},
|
|
3132
|
+
];
|
|
3133
|
+
const slackDefault = Boolean(config?.deliveries?.slack?.enabled);
|
|
3134
|
+
if (await askYesNo(rl, 'Send summaries and connector-health alerts to Slack?', slackDefault)) {
|
|
3135
|
+
const webhookEnv = await ask(rl, 'Slack webhook env var', config?.deliveries?.slack?.webhookEnv || 'SLACK_WEBHOOK_URL');
|
|
3136
|
+
channels.push({ type: 'slack', enabled: true, webhookEnv });
|
|
3137
|
+
}
|
|
3138
|
+
const webhookDefault = Boolean(config?.deliveries?.webhook?.enabled);
|
|
3139
|
+
if (await askYesNo(rl, 'Send summaries and connector-health alerts to a generic webhook/social bridge?', webhookDefault)) {
|
|
3140
|
+
const urlEnv = await ask(rl, 'Webhook URL env var', config?.deliveries?.webhook?.urlEnv || 'OPENCLAW_WEBHOOK_URL');
|
|
3141
|
+
channels.push({ type: 'webhook', enabled: true, urlEnv, method: 'POST', headers: {} });
|
|
3142
|
+
}
|
|
3143
|
+
const commandDefault = Boolean(config?.deliveries?.discord?.enabled);
|
|
3144
|
+
if (await askYesNo(rl, 'Send summaries and connector-health alerts through a local command channel?', commandDefault)) {
|
|
3145
|
+
const command = await ask(rl, 'Command that receives the message on stdin', config?.deliveries?.discord?.command || 'node scripts/discord-openclaw-bridge.mjs send --stdin');
|
|
3146
|
+
channels.push({ type: 'command', enabled: true, label: 'command', command });
|
|
3147
|
+
}
|
|
3148
|
+
return channels;
|
|
3149
|
+
}
|
|
3150
|
+
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');
|
|
3155
|
+
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);
|
|
3166
|
+
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
|
+
};
|
|
3173
|
+
}
|
|
3174
|
+
const channels = await askNotificationChannels(rl, config);
|
|
3175
|
+
const connectorHealthChannels = channels.map((channel) => {
|
|
3176
|
+
if (channel.type !== 'openclaw-chat')
|
|
3177
|
+
return channel;
|
|
3178
|
+
return {
|
|
3179
|
+
...channel,
|
|
3180
|
+
markdownPath: '.openclaw/chat/connector-health.md',
|
|
3181
|
+
jsonPath: '.openclaw/chat/connector-health.json',
|
|
3182
|
+
};
|
|
3183
|
+
});
|
|
3184
|
+
config.actions = {
|
|
3185
|
+
...(config.actions || {}),
|
|
3186
|
+
mode,
|
|
3187
|
+
autoCreateIssues: mode === 'issue' && autoCreate,
|
|
3188
|
+
autoCreatePullRequests: mode === 'pull_request' && autoCreate,
|
|
3189
|
+
draftPullRequests: true,
|
|
3190
|
+
proposalBranchPrefix: config?.actions?.proposalBranchPrefix || 'openclaw/proposals',
|
|
3191
|
+
};
|
|
3192
|
+
config.deliveries = {
|
|
3193
|
+
...(config.deliveries || {}),
|
|
3194
|
+
openclawChat: {
|
|
3195
|
+
...(config.deliveries?.openclawChat || {}),
|
|
3196
|
+
enabled: true,
|
|
3197
|
+
markdownPath: config.deliveries?.openclawChat?.markdownPath || '.openclaw/chat/latest.md',
|
|
3198
|
+
jsonPath: config.deliveries?.openclawChat?.jsonPath || '.openclaw/chat/latest.json',
|
|
3199
|
+
},
|
|
3200
|
+
github: {
|
|
3201
|
+
...(config.deliveries?.github || {}),
|
|
3202
|
+
enabled: !summaryOnly,
|
|
3203
|
+
mode,
|
|
3204
|
+
autoCreate,
|
|
3205
|
+
draftPullRequests: true,
|
|
3206
|
+
proposalBranchPrefix: config?.actions?.proposalBranchPrefix || 'openclaw/proposals',
|
|
3207
|
+
},
|
|
3208
|
+
slack: {
|
|
3209
|
+
...(config.deliveries?.slack || {}),
|
|
3210
|
+
enabled: channels.some((channel) => channel.type === 'slack'),
|
|
3211
|
+
webhookEnv: channels.find((channel) => channel.type === 'slack')?.webhookEnv || config.deliveries?.slack?.webhookEnv || 'SLACK_WEBHOOK_URL',
|
|
3212
|
+
},
|
|
3213
|
+
webhook: {
|
|
3214
|
+
...(config.deliveries?.webhook || {}),
|
|
3215
|
+
enabled: channels.some((channel) => channel.type === 'webhook'),
|
|
3216
|
+
urlEnv: channels.find((channel) => channel.type === 'webhook')?.urlEnv || config.deliveries?.webhook?.urlEnv || 'OPENCLAW_WEBHOOK_URL',
|
|
3217
|
+
method: 'POST',
|
|
3218
|
+
headers: config.deliveries?.webhook?.headers || {},
|
|
3219
|
+
},
|
|
3220
|
+
discord: {
|
|
3221
|
+
...(config.deliveries?.discord || {}),
|
|
3222
|
+
enabled: channels.some((channel) => channel.type === 'command'),
|
|
3223
|
+
command: channels.find((channel) => channel.type === 'command')?.command || config.deliveries?.discord?.command || 'node scripts/discord-openclaw-bridge.mjs send --stdin',
|
|
3224
|
+
},
|
|
3225
|
+
};
|
|
3226
|
+
config.notifications = {
|
|
3227
|
+
...(config.notifications || {}),
|
|
3228
|
+
connectorHealth: {
|
|
3229
|
+
...(config.notifications?.connectorHealth || {}),
|
|
3230
|
+
enabled: true,
|
|
3231
|
+
channels: mergeNotificationChannels([], connectorHealthChannels),
|
|
3232
|
+
},
|
|
3233
|
+
growthRun: {
|
|
3234
|
+
...(config.notifications?.growthRun || {}),
|
|
3235
|
+
enabled: true,
|
|
3236
|
+
channels: mergeNotificationChannels([], channels),
|
|
3237
|
+
},
|
|
3238
|
+
};
|
|
3239
|
+
return config;
|
|
3240
|
+
}
|
|
3241
|
+
async function askIntervalConfig(rl, config) {
|
|
3242
|
+
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
|
+
const usageMode = await askToolUsage(rl);
|
|
3246
|
+
const cadences = await askCadencePlan(rl);
|
|
3247
|
+
config.schedule = {
|
|
3248
|
+
...currentSchedule,
|
|
3249
|
+
intervalMinutes,
|
|
3250
|
+
connectorHealthCheckIntervalMinutes,
|
|
3251
|
+
skipIfNoDataChange: currentSchedule.skipIfNoDataChange !== false,
|
|
3252
|
+
skipIfIssueSetUnchanged: currentSchedule.skipIfIssueSetUnchanged !== false,
|
|
3253
|
+
cadences,
|
|
3254
|
+
};
|
|
3255
|
+
config.actions = {
|
|
3256
|
+
...(config.actions || {}),
|
|
3257
|
+
usageMode,
|
|
3258
|
+
};
|
|
3259
|
+
return config;
|
|
3260
|
+
}
|
|
3261
|
+
async function writeOpenClawJobManifest(configPath, config) {
|
|
3262
|
+
const manifestPath = path.resolve('.openclaw/jobs/openclaw-growth-engineer.json');
|
|
3263
|
+
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));
|
|
3266
|
+
const actionMode = config?.actions?.mode || config?.deliveries?.github?.mode || 'issue';
|
|
3267
|
+
const growthRunCommand = getGrowthRunCommand(config, displayConfigPath);
|
|
3268
|
+
const connectorHealthCommand = getConnectorHealthCommand(config, displayConfigPath);
|
|
3269
|
+
const manifest = {
|
|
3270
|
+
version: 1,
|
|
3271
|
+
generatedAt: new Date().toISOString(),
|
|
3272
|
+
managedBy: 'openclaw-growth-wizard',
|
|
3273
|
+
agentPolicy: {
|
|
3274
|
+
openClawCanRunGrowthJobs: true,
|
|
3275
|
+
openClawCanEditGrowthCadences: true,
|
|
3276
|
+
openClawCanEditOutputDelivery: true,
|
|
3277
|
+
openClawCanEditConnectors: true,
|
|
3278
|
+
openClawCanEditConnectorSecrets: false,
|
|
3279
|
+
connectorChanges: 'OpenClaw may read and modify non-secret connector config such as enabled flags, source commands, project/app mappings, and source priorities. Use `node scripts/openclaw-growth-wizard.mjs --connectors` for API keys or other connector secrets; never write secret values into config files, manifests, issues, PRs, or chat output.',
|
|
3280
|
+
secretAccessMode: config?.security?.connectorSecrets?.mode || 'local-user-file',
|
|
3281
|
+
secretPolicy: config?.security?.connectorSecrets?.mode === 'isolated-runner'
|
|
3282
|
+
? 'OpenClaw must use the allowlisted sudo wrapper commands and must not read the persisted secret file.'
|
|
3283
|
+
: 'Secrets are persisted in a local chmod 600 file. This protects against other OS users, not against the same OS user.',
|
|
3284
|
+
},
|
|
3285
|
+
jobs: [
|
|
3286
|
+
{
|
|
3287
|
+
key: 'connector-health',
|
|
3288
|
+
kind: 'health-check',
|
|
3289
|
+
intervalMinutes: connectorHealthCheckIntervalMinutes,
|
|
3290
|
+
command: connectorHealthCommand,
|
|
3291
|
+
notificationPolicy: 'once_per_unhealthy_incident_until_recovery_or_changed_fingerprint',
|
|
3292
|
+
},
|
|
3293
|
+
{
|
|
3294
|
+
key: 'growth-runner',
|
|
3295
|
+
kind: 'growth-analysis',
|
|
3296
|
+
intervalMinutes,
|
|
3297
|
+
command: growthRunCommand,
|
|
3298
|
+
outputMode: actionMode,
|
|
3299
|
+
cadences: Array.isArray(config?.schedule?.cadences) ? config.schedule.cadences : [],
|
|
3300
|
+
},
|
|
3301
|
+
],
|
|
3302
|
+
};
|
|
3303
|
+
await writeJsonFile(manifestPath, manifest);
|
|
3304
|
+
return manifestPath;
|
|
3305
|
+
}
|
|
3306
|
+
async function main() {
|
|
3307
|
+
await loadOpenClawGrowthSecrets();
|
|
3308
|
+
const args = parseArgs(process.argv.slice(2));
|
|
3309
|
+
await maybeSelfUpdateFromClawHub(args);
|
|
3310
|
+
if (args.connectorWizard) {
|
|
3311
|
+
await runConnectorSetupWizard(args);
|
|
3312
|
+
return;
|
|
3313
|
+
}
|
|
3314
|
+
const configPath = path.resolve(args.out);
|
|
3315
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
3316
|
+
throw new Error('Wizard requires an interactive terminal.');
|
|
3317
|
+
}
|
|
3318
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
3319
|
+
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');
|
|
3322
|
+
const goal = await askWizardGoal(rl);
|
|
3323
|
+
if (goal === 'connectors') {
|
|
3324
|
+
rl.close();
|
|
3325
|
+
await runConnectorSetupWizard({ ...args, connectorWizard: true });
|
|
3326
|
+
return;
|
|
3327
|
+
}
|
|
3328
|
+
if (goal === 'intervals') {
|
|
3329
|
+
const config = await askIntervalConfig(rl, await loadEditableConfig(configPath));
|
|
3330
|
+
const secretAccess = await askSecretAccessModel(rl, configPath, config);
|
|
3331
|
+
await writeJsonFile(configPath, config);
|
|
3332
|
+
const manifestPath = await writeOpenClawJobManifest(configPath, config);
|
|
3333
|
+
process.stdout.write(`\nSaved schedule config: ${configPath}\n`);
|
|
3334
|
+
process.stdout.write(`Saved OpenClaw job manifest: ${manifestPath}\n`);
|
|
3335
|
+
printSecretRunnerKitInstructions(secretAccess.kit);
|
|
3336
|
+
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
|
+
return;
|
|
3338
|
+
}
|
|
3339
|
+
if (goal === 'output') {
|
|
3340
|
+
const config = await askOutputConfig(rl, await loadEditableConfig(configPath));
|
|
3341
|
+
const secretAccess = await askSecretAccessModel(rl, configPath, config);
|
|
3342
|
+
await writeJsonFile(configPath, config);
|
|
3343
|
+
const manifestPath = await writeOpenClawJobManifest(configPath, config);
|
|
3344
|
+
process.stdout.write(`\nSaved output config: ${configPath}\n`);
|
|
3345
|
+
process.stdout.write(`Saved OpenClaw job manifest: ${manifestPath}\n`);
|
|
3346
|
+
printSecretRunnerKitInstructions(secretAccess.kit);
|
|
3347
|
+
process.stdout.write('Connector-health alerts are deduped per unhealthy incident and sent through configured channels.\n');
|
|
3348
|
+
return;
|
|
3349
|
+
}
|
|
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) });
|
|
3382
|
+
});
|
|
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
|
+
};
|
|
3493
|
+
const secretAccess = await askSecretAccessModel(rl, configPath, config);
|
|
3494
|
+
await ensureDirForFile(configPath);
|
|
3495
|
+
await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
|
|
3496
|
+
const manifestPath = await writeOpenClawJobManifest(configPath, config);
|
|
3497
|
+
process.stdout.write(`\nSaved config: ${configPath}\n`);
|
|
3498
|
+
process.stdout.write(`Saved OpenClaw job manifest: ${manifestPath}\n`);
|
|
3499
|
+
printSecretRunnerKitInstructions(secretAccess.kit);
|
|
3500
|
+
process.stdout.write('\nNext steps:\n');
|
|
3501
|
+
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`);
|
|
3510
|
+
}
|
|
3511
|
+
finally {
|
|
3512
|
+
rl.close();
|
|
3513
|
+
}
|
|
3514
|
+
}
|
|
3515
|
+
main().catch((error) => {
|
|
3516
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
3517
|
+
process.exitCode = 1;
|
|
3518
|
+
});
|
|
3519
|
+
//# sourceMappingURL=openclaw-growth-wizard.mjs.map
|