@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,1111 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, 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 { classifyServiceKind, getActionMode, getAllSourceEntries, getDefaultSourceCommand, getGitHubActionNoun, getGitHubConnectionSummary, getGitHubRequirementText, shouldAutoCreateGitHubArtifact, } from './openclaw-growth-shared.mjs';
|
|
7
|
+
import { applyOpenClawSecretRefs, loadOpenClawGrowthSecrets } from './openclaw-growth-env.mjs';
|
|
8
|
+
const DEFAULT_CONFIG_PATH = 'data/openclaw-growth-engineer/config.json';
|
|
9
|
+
const DEFAULT_CONNECTION_TIMEOUT_MS = 15_000;
|
|
10
|
+
const ANALYTICSCLI_PACKAGE_SPEC = process.env.ANALYTICSCLI_CLI_PACKAGE || '@analyticscli/cli@preview';
|
|
11
|
+
const ANALYTICSCLI_NPM_PREFIX = process.env.ANALYTICSCLI_NPM_PREFIX ||
|
|
12
|
+
(process.env.HOME ? path.join(process.env.HOME, '.local') : path.join(process.cwd(), '.analyticscli-npm'));
|
|
13
|
+
function printHelpAndExit(exitCode, reason = null) {
|
|
14
|
+
if (reason) {
|
|
15
|
+
process.stderr.write(`${reason}\n\n`);
|
|
16
|
+
}
|
|
17
|
+
process.stdout.write(`
|
|
18
|
+
OpenClaw Growth Preflight
|
|
19
|
+
|
|
20
|
+
Validates local dependencies, configured sources, and required secrets.
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
node scripts/openclaw-growth-preflight.mjs [options]
|
|
24
|
+
|
|
25
|
+
Options:
|
|
26
|
+
--config <file> Config path (default: ${DEFAULT_CONFIG_PATH})
|
|
27
|
+
--test-connections Run live API/connector smoke checks for enabled channels
|
|
28
|
+
--only-connectors <list>
|
|
29
|
+
Limit live checks to analytics,github,asc,revenuecat,sentry
|
|
30
|
+
--timeout-ms <ms> Connection test timeout in milliseconds (default: ${DEFAULT_CONNECTION_TIMEOUT_MS})
|
|
31
|
+
--progress-json Emit machine-readable progress events on stderr
|
|
32
|
+
--json Print JSON only (default)
|
|
33
|
+
--help, -h Show help
|
|
34
|
+
`);
|
|
35
|
+
process.exit(exitCode);
|
|
36
|
+
}
|
|
37
|
+
function parseArgs(argv) {
|
|
38
|
+
const args = {
|
|
39
|
+
config: DEFAULT_CONFIG_PATH,
|
|
40
|
+
json: true,
|
|
41
|
+
progressJson: false,
|
|
42
|
+
testConnections: false,
|
|
43
|
+
onlyConnectors: [],
|
|
44
|
+
timeoutMs: DEFAULT_CONNECTION_TIMEOUT_MS,
|
|
45
|
+
};
|
|
46
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
47
|
+
const token = argv[i];
|
|
48
|
+
const next = argv[i + 1];
|
|
49
|
+
if (token === '--') {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
else if (token === '--config') {
|
|
53
|
+
args.config = next || args.config;
|
|
54
|
+
i += 1;
|
|
55
|
+
}
|
|
56
|
+
else if (token === '--test-connections') {
|
|
57
|
+
args.testConnections = true;
|
|
58
|
+
}
|
|
59
|
+
else if (token === '--only-connectors') {
|
|
60
|
+
args.onlyConnectors = parseConnectorList(next || '');
|
|
61
|
+
i += 1;
|
|
62
|
+
}
|
|
63
|
+
else if (token === '--progress-json') {
|
|
64
|
+
args.progressJson = true;
|
|
65
|
+
}
|
|
66
|
+
else if (token === '--timeout-ms') {
|
|
67
|
+
const parsed = Number.parseInt(String(next || ''), 10);
|
|
68
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
69
|
+
printHelpAndExit(1, `Invalid value for --timeout-ms: ${String(next || '')}`);
|
|
70
|
+
}
|
|
71
|
+
args.timeoutMs = parsed;
|
|
72
|
+
i += 1;
|
|
73
|
+
}
|
|
74
|
+
else if (token === '--json') {
|
|
75
|
+
args.json = true;
|
|
76
|
+
}
|
|
77
|
+
else if (token === '--help' || token === '-h') {
|
|
78
|
+
printHelpAndExit(0);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
printHelpAndExit(1, `Unknown argument: ${token}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return args;
|
|
85
|
+
}
|
|
86
|
+
function normalizeConnectorKey(value) {
|
|
87
|
+
const normalized = String(value || '').trim().toLowerCase().replace(/[_\s]+/g, '-');
|
|
88
|
+
if (!normalized)
|
|
89
|
+
return null;
|
|
90
|
+
if (normalized === 'all')
|
|
91
|
+
return 'all';
|
|
92
|
+
if (['analytics', 'analyticscli', 'product-analytics', 'events'].includes(normalized))
|
|
93
|
+
return 'analytics';
|
|
94
|
+
if (['github', 'gh', 'github-code', 'codebase', 'code-access'].includes(normalized))
|
|
95
|
+
return 'github';
|
|
96
|
+
if (['asc', 'asc-cli', 'app-store-connect', 'appstoreconnect', 'app-store'].includes(normalized))
|
|
97
|
+
return 'asc';
|
|
98
|
+
if (['revenuecat', 'revenue-cat', 'rc', 'revenuecat-mcp'].includes(normalized))
|
|
99
|
+
return 'revenuecat';
|
|
100
|
+
if (['sentry', 'sentry-api', 'sentry-mcp', 'glitchtip', 'crashes', 'errors', 'crash-reporting'].includes(normalized))
|
|
101
|
+
return 'sentry';
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
function parseConnectorList(value) {
|
|
105
|
+
if (!String(value || '').trim())
|
|
106
|
+
return [];
|
|
107
|
+
const connectors = new Set();
|
|
108
|
+
for (const entry of String(value).split(',')) {
|
|
109
|
+
const connector = normalizeConnectorKey(entry);
|
|
110
|
+
if (!connector) {
|
|
111
|
+
printHelpAndExit(1, `Unknown connector: ${entry.trim()}. Use analytics, github, asc, revenuecat, sentry, or all.`);
|
|
112
|
+
}
|
|
113
|
+
if (connector === 'all') {
|
|
114
|
+
connectors.add('analytics');
|
|
115
|
+
connectors.add('github');
|
|
116
|
+
connectors.add('asc');
|
|
117
|
+
connectors.add('revenuecat');
|
|
118
|
+
connectors.add('sentry');
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
connectors.add(connector);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return [...connectors];
|
|
125
|
+
}
|
|
126
|
+
function shellQuote(value) {
|
|
127
|
+
if (/^[a-zA-Z0-9_./:-]+$/.test(String(value))) {
|
|
128
|
+
return String(value);
|
|
129
|
+
}
|
|
130
|
+
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
131
|
+
}
|
|
132
|
+
function resolveShellCommand() {
|
|
133
|
+
const candidates = [
|
|
134
|
+
process.env.OPENCLAW_SHELL,
|
|
135
|
+
process.env.SHELL,
|
|
136
|
+
'/bin/zsh',
|
|
137
|
+
'/bin/bash',
|
|
138
|
+
'/usr/bin/bash',
|
|
139
|
+
'/bin/sh',
|
|
140
|
+
'/usr/bin/sh',
|
|
141
|
+
].filter((value) => typeof value === 'string' && value.trim().length > 0);
|
|
142
|
+
for (const candidate of candidates) {
|
|
143
|
+
if (existsSync(candidate)) {
|
|
144
|
+
return candidate;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return 'sh';
|
|
148
|
+
}
|
|
149
|
+
function runShell(command, options = {}) {
|
|
150
|
+
return new Promise((resolve) => {
|
|
151
|
+
const child = spawn(resolveShellCommand(), ['-c', command], {
|
|
152
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
153
|
+
cwd: options.cwd,
|
|
154
|
+
env: options.env ? { ...process.env, ...options.env } : process.env,
|
|
155
|
+
});
|
|
156
|
+
let stdout = '';
|
|
157
|
+
let stderr = '';
|
|
158
|
+
let settled = false;
|
|
159
|
+
const timeoutMs = options.timeoutMs ?? 60_000;
|
|
160
|
+
const timer = setTimeout(() => {
|
|
161
|
+
if (settled)
|
|
162
|
+
return;
|
|
163
|
+
settled = true;
|
|
164
|
+
child.kill('SIGTERM');
|
|
165
|
+
resolve({
|
|
166
|
+
ok: false,
|
|
167
|
+
code: null,
|
|
168
|
+
stdout,
|
|
169
|
+
stderr: `${stderr}\nTimed out after ${timeoutMs}ms`,
|
|
170
|
+
});
|
|
171
|
+
}, timeoutMs);
|
|
172
|
+
child.stdout.on('data', (chunk) => {
|
|
173
|
+
stdout += String(chunk);
|
|
174
|
+
});
|
|
175
|
+
child.stderr.on('data', (chunk) => {
|
|
176
|
+
stderr += String(chunk);
|
|
177
|
+
});
|
|
178
|
+
child.on('close', (code) => {
|
|
179
|
+
if (settled)
|
|
180
|
+
return;
|
|
181
|
+
settled = true;
|
|
182
|
+
clearTimeout(timer);
|
|
183
|
+
resolve({
|
|
184
|
+
ok: code === 0,
|
|
185
|
+
code,
|
|
186
|
+
stdout,
|
|
187
|
+
stderr,
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
async function commandExists(commandName) {
|
|
193
|
+
const result = await runShell(`command -v ${shellQuote(commandName)} >/dev/null 2>&1`);
|
|
194
|
+
return result.ok;
|
|
195
|
+
}
|
|
196
|
+
async function resolveCommandPath(commandName) {
|
|
197
|
+
const result = await runShell(`command -v ${shellQuote(commandName)}`);
|
|
198
|
+
return result.ok ? result.stdout.trim() : null;
|
|
199
|
+
}
|
|
200
|
+
function prependToPath(binDir) {
|
|
201
|
+
process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH || ''}`;
|
|
202
|
+
}
|
|
203
|
+
function getPathProfileEntries(binDir) {
|
|
204
|
+
const entries = [binDir];
|
|
205
|
+
if (process.env.HOME && path.resolve(binDir) === path.resolve(process.env.HOME, '.local', 'bin')) {
|
|
206
|
+
entries.push(path.join(process.env.HOME, '.local', 'analyticscli-npm', 'bin'));
|
|
207
|
+
}
|
|
208
|
+
return entries;
|
|
209
|
+
}
|
|
210
|
+
function renderProfilePathEntries(binDir) {
|
|
211
|
+
const home = process.env.HOME ? path.resolve(process.env.HOME) : null;
|
|
212
|
+
return getPathProfileEntries(binDir)
|
|
213
|
+
.map((entry) => {
|
|
214
|
+
const resolved = path.resolve(entry);
|
|
215
|
+
if (home && (resolved === home || resolved.startsWith(`${home}${path.sep}`))) {
|
|
216
|
+
return `$HOME/${path.relative(home, resolved)}`;
|
|
217
|
+
}
|
|
218
|
+
return entry;
|
|
219
|
+
})
|
|
220
|
+
.join(':');
|
|
221
|
+
}
|
|
222
|
+
async function ensureProfilePath(binDir) {
|
|
223
|
+
if (process.env.ANALYTICSCLI_SKIP_PROFILE_UPDATE === 'true' || !process.env.HOME) {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
const line = `export PATH="${renderProfilePathEntries(binDir)}:$PATH"`;
|
|
227
|
+
const profiles = ['.profile', '.bashrc', '.bash_profile', '.zshrc', '.zprofile'].map((name) => path.join(process.env.HOME, name));
|
|
228
|
+
let wrote = false;
|
|
229
|
+
for (const profile of profiles) {
|
|
230
|
+
let current = '';
|
|
231
|
+
try {
|
|
232
|
+
current = await fs.readFile(profile, 'utf8');
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
await fs.mkdir(path.dirname(profile), { recursive: true });
|
|
236
|
+
}
|
|
237
|
+
if (!current.includes(line)) {
|
|
238
|
+
await fs.appendFile(profile, `\n# AnalyticsCLI CLI user-local npm bin\n${line}\n`, 'utf8');
|
|
239
|
+
wrote = true;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return wrote;
|
|
243
|
+
}
|
|
244
|
+
async function verifyFreshShellProfile() {
|
|
245
|
+
if (!process.env.HOME) {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
const cleanPath = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin';
|
|
249
|
+
const probes = [
|
|
250
|
+
{
|
|
251
|
+
shell: '/bin/bash',
|
|
252
|
+
command: 'for f in "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.profile"; do [[ -f "$f" ]] && source "$f" >/dev/null 2>&1 || true; done; command -v analyticscli >/dev/null 2>&1 && analyticscli --help >/dev/null 2>&1',
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
shell: '/usr/bin/bash',
|
|
256
|
+
command: 'for f in "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.profile"; do [[ -f "$f" ]] && source "$f" >/dev/null 2>&1 || true; done; command -v analyticscli >/dev/null 2>&1 && analyticscli --help >/dev/null 2>&1',
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
shell: '/bin/zsh',
|
|
260
|
+
command: 'for f in "$HOME/.zprofile" "$HOME/.zshrc" "$HOME/.profile"; do [[ -f "$f" ]] && source "$f" >/dev/null 2>&1 || true; done; command -v analyticscli >/dev/null 2>&1 && analyticscli --help >/dev/null 2>&1',
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
shell: '/usr/bin/zsh',
|
|
264
|
+
command: 'for f in "$HOME/.zprofile" "$HOME/.zshrc" "$HOME/.profile"; do [[ -f "$f" ]] && source "$f" >/dev/null 2>&1 || true; done; command -v analyticscli >/dev/null 2>&1 && analyticscli --help >/dev/null 2>&1',
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
shell: '/bin/sh',
|
|
268
|
+
command: '[ -f "$HOME/.profile" ] && . "$HOME/.profile" >/dev/null 2>&1 || true; command -v analyticscli >/dev/null 2>&1 && analyticscli --help >/dev/null 2>&1',
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
shell: '/usr/bin/sh',
|
|
272
|
+
command: '[ -f "$HOME/.profile" ] && . "$HOME/.profile" >/dev/null 2>&1 || true; command -v analyticscli >/dev/null 2>&1 && analyticscli --help >/dev/null 2>&1',
|
|
273
|
+
},
|
|
274
|
+
];
|
|
275
|
+
for (const probe of probes) {
|
|
276
|
+
if (!(await fileExists(probe.shell))) {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const result = await runShell(`env HOME=${shellQuote(process.env.HOME)} PATH=${shellQuote(cleanPath)} ${shellQuote(probe.shell)} -lc ${shellQuote(probe.command)}`);
|
|
280
|
+
if (result.ok) {
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
function isUserLocalBin(binDir) {
|
|
287
|
+
if (!process.env.HOME) {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
const home = path.resolve(process.env.HOME);
|
|
291
|
+
const resolved = path.resolve(binDir);
|
|
292
|
+
return resolved === home || resolved.startsWith(`${home}${path.sep}`);
|
|
293
|
+
}
|
|
294
|
+
function isPermissionFailure(output) {
|
|
295
|
+
return /EACCES|permission denied|access denied|operation not permitted/i.test(String(output || ''));
|
|
296
|
+
}
|
|
297
|
+
async function ensureAnalyticsCliInstalled() {
|
|
298
|
+
const beforePath = await resolveCommandPath('analyticscli');
|
|
299
|
+
const npmExists = await commandExists('npm');
|
|
300
|
+
if (!npmExists) {
|
|
301
|
+
return beforePath
|
|
302
|
+
? {
|
|
303
|
+
ok: true,
|
|
304
|
+
detail: `analyticscli binary found at ${beforePath}; npm unavailable, so package update was skipped`,
|
|
305
|
+
}
|
|
306
|
+
: {
|
|
307
|
+
ok: false,
|
|
308
|
+
detail: `analyticscli binary missing and npm is unavailable; install ${ANALYTICSCLI_PACKAGE_SPEC}`,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
const globalInstall = await runShell(`npm install -g ${shellQuote(ANALYTICSCLI_PACKAGE_SPEC)}`);
|
|
312
|
+
if (!globalInstall.ok) {
|
|
313
|
+
const installOutput = `${globalInstall.stderr}\n${globalInstall.stdout}`;
|
|
314
|
+
if (isPermissionFailure(installOutput)) {
|
|
315
|
+
await fs.mkdir(ANALYTICSCLI_NPM_PREFIX, { recursive: true });
|
|
316
|
+
const localInstall = await runShell(`npm install -g --prefix ${shellQuote(ANALYTICSCLI_NPM_PREFIX)} ${shellQuote(ANALYTICSCLI_PACKAGE_SPEC)}`);
|
|
317
|
+
if (!localInstall.ok) {
|
|
318
|
+
return beforePath
|
|
319
|
+
? {
|
|
320
|
+
ok: true,
|
|
321
|
+
detail: `analyticscli binary found at ${beforePath}; update failed globally and in user-local prefix (${truncate(localInstall.stderr || localInstall.stdout)})`,
|
|
322
|
+
}
|
|
323
|
+
: {
|
|
324
|
+
ok: false,
|
|
325
|
+
detail: `npm install failed globally and in user-local prefix ${ANALYTICSCLI_NPM_PREFIX}: ${truncate(localInstall.stderr || localInstall.stdout)}`,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
const localBinDir = path.join(ANALYTICSCLI_NPM_PREFIX, 'bin');
|
|
329
|
+
prependToPath(localBinDir);
|
|
330
|
+
await ensureProfilePath(localBinDir);
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
return beforePath
|
|
334
|
+
? {
|
|
335
|
+
ok: true,
|
|
336
|
+
detail: `analyticscli binary found at ${beforePath}; package update failed (${truncate(installOutput)})`,
|
|
337
|
+
}
|
|
338
|
+
: {
|
|
339
|
+
ok: false,
|
|
340
|
+
detail: `npm install -g ${ANALYTICSCLI_PACKAGE_SPEC} failed: ${truncate(installOutput)}`,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
const afterPath = await resolveCommandPath('analyticscli');
|
|
345
|
+
if (afterPath) {
|
|
346
|
+
const helpCheck = await runShell('analyticscli --help >/dev/null 2>&1');
|
|
347
|
+
if (!helpCheck.ok) {
|
|
348
|
+
return {
|
|
349
|
+
ok: false,
|
|
350
|
+
detail: `analyticscli binary found at ${afterPath}, but --help failed: ${truncate(helpCheck.stderr || helpCheck.stdout)}`,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
const binDir = path.dirname(afterPath);
|
|
354
|
+
if (isUserLocalBin(binDir)) {
|
|
355
|
+
await ensureProfilePath(binDir);
|
|
356
|
+
if (!(await verifyFreshShellProfile())) {
|
|
357
|
+
return {
|
|
358
|
+
ok: false,
|
|
359
|
+
detail: `analyticscli works at ${afterPath}, but a fresh shell still cannot resolve it after profile update; add ${renderProfilePathEntries(binDir)} to PATH`,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
return {
|
|
363
|
+
ok: true,
|
|
364
|
+
detail: `analyticscli package ensured via ${ANALYTICSCLI_PACKAGE_SPEC}; binary found at ${afterPath}; shell profiles updated and fresh shell verification passed`,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return afterPath
|
|
369
|
+
? {
|
|
370
|
+
ok: true,
|
|
371
|
+
detail: `analyticscli package ensured via ${ANALYTICSCLI_PACKAGE_SPEC}; binary found at ${afterPath}`,
|
|
372
|
+
}
|
|
373
|
+
: {
|
|
374
|
+
ok: false,
|
|
375
|
+
detail: `Installed ${ANALYTICSCLI_PACKAGE_SPEC}, but analyticscli is still not on PATH`,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
function parseCommandHead(command) {
|
|
379
|
+
if (!command || typeof command !== 'string')
|
|
380
|
+
return null;
|
|
381
|
+
const trimmed = command.trim();
|
|
382
|
+
if (!trimmed)
|
|
383
|
+
return null;
|
|
384
|
+
const parts = trimmed.split(/\s+/).filter(Boolean);
|
|
385
|
+
return parts.length > 0 ? parts[0] : null;
|
|
386
|
+
}
|
|
387
|
+
function isPortableCommandDefault(sourceName, command) {
|
|
388
|
+
const expected = getDefaultSourceCommand(sourceName);
|
|
389
|
+
if (!expected)
|
|
390
|
+
return false;
|
|
391
|
+
return String(command || '').trim().startsWith(expected);
|
|
392
|
+
}
|
|
393
|
+
function truncate(value, max = 240) {
|
|
394
|
+
const text = String(value || '');
|
|
395
|
+
if (text.length <= max)
|
|
396
|
+
return text;
|
|
397
|
+
return `${text.slice(0, max)}…`;
|
|
398
|
+
}
|
|
399
|
+
function sleep(ms) {
|
|
400
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
401
|
+
}
|
|
402
|
+
function isTransientNetworkFailure(value) {
|
|
403
|
+
return /NETWORK_ERROR|fetch failed|tlsv1 alert|SSL routines|ECONNRESET|ECONNREFUSED|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket hang up|network timeout|Temporary failure/i.test(String(value || ''));
|
|
404
|
+
}
|
|
405
|
+
async function readJson(filePath) {
|
|
406
|
+
const raw = await fs.readFile(filePath, 'utf8');
|
|
407
|
+
return JSON.parse(raw);
|
|
408
|
+
}
|
|
409
|
+
async function fileExists(filePath) {
|
|
410
|
+
try {
|
|
411
|
+
await fs.access(filePath);
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
catch {
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
function addCheck(checks, name, ok, detail, severity = 'fail') {
|
|
419
|
+
checks.push({
|
|
420
|
+
name,
|
|
421
|
+
status: ok ? 'pass' : severity,
|
|
422
|
+
detail,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
function emitProgress(enabled, event) {
|
|
426
|
+
if (!enabled)
|
|
427
|
+
return;
|
|
428
|
+
process.stderr.write(`OPENCLAW_PROGRESS ${JSON.stringify(event)}\n`);
|
|
429
|
+
}
|
|
430
|
+
function checkSliceStatus(checks, startIndex) {
|
|
431
|
+
const slice = checks.slice(startIndex);
|
|
432
|
+
if (slice.some((check) => check.status === 'fail'))
|
|
433
|
+
return 'fail';
|
|
434
|
+
if (slice.some((check) => check.status === 'warn'))
|
|
435
|
+
return 'warn';
|
|
436
|
+
return 'pass';
|
|
437
|
+
}
|
|
438
|
+
async function runProgressGroup({ checks, progressJson, key, label, detail, run }) {
|
|
439
|
+
emitProgress(progressJson, { phase: 'start', key, label, detail });
|
|
440
|
+
const startIndex = checks.length;
|
|
441
|
+
try {
|
|
442
|
+
await run();
|
|
443
|
+
}
|
|
444
|
+
finally {
|
|
445
|
+
emitProgress(progressJson, {
|
|
446
|
+
phase: 'finish',
|
|
447
|
+
key,
|
|
448
|
+
label,
|
|
449
|
+
detail,
|
|
450
|
+
status: checkSliceStatus(checks, startIndex),
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
function scheduleProgressGroup(tasks, checks, progressJson, { key, label, detail, run }) {
|
|
455
|
+
tasks.push((async () => {
|
|
456
|
+
const groupChecks = [];
|
|
457
|
+
await runProgressGroup({
|
|
458
|
+
checks: groupChecks,
|
|
459
|
+
progressJson,
|
|
460
|
+
key,
|
|
461
|
+
label,
|
|
462
|
+
detail,
|
|
463
|
+
run: () => run(groupChecks),
|
|
464
|
+
});
|
|
465
|
+
checks.push(...groupChecks);
|
|
466
|
+
})());
|
|
467
|
+
}
|
|
468
|
+
function getSecretName(config, key, fallback) {
|
|
469
|
+
const value = config?.secrets?.[key];
|
|
470
|
+
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
|
|
471
|
+
}
|
|
472
|
+
function sourceEnabled(config, sourceName) {
|
|
473
|
+
return Boolean(config?.sources?.[sourceName] && config.sources[sourceName].enabled !== false);
|
|
474
|
+
}
|
|
475
|
+
function isConfiguredGitHubRepo(value) {
|
|
476
|
+
const repo = String(value || '').trim();
|
|
477
|
+
return Boolean(repo && repo !== 'owner/repo' && /^[^/\s]+\/[^/\s]+$/.test(repo));
|
|
478
|
+
}
|
|
479
|
+
async function fetchWithTimeout(url, options, timeoutMs) {
|
|
480
|
+
const controller = new AbortController();
|
|
481
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
482
|
+
try {
|
|
483
|
+
const response = await fetch(url, {
|
|
484
|
+
...options,
|
|
485
|
+
signal: controller.signal,
|
|
486
|
+
});
|
|
487
|
+
const body = await response.text();
|
|
488
|
+
return { ok: response.ok, status: response.status, body };
|
|
489
|
+
}
|
|
490
|
+
finally {
|
|
491
|
+
clearTimeout(timer);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
async function testAnalyticsConnection(analyticsToken, analyticsTokenEnv, timeoutMs) {
|
|
495
|
+
const hasCli = await commandExists('analyticscli');
|
|
496
|
+
if (!hasCli) {
|
|
497
|
+
return {
|
|
498
|
+
ok: false,
|
|
499
|
+
detail: 'analyticscli binary missing',
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
const runCheck = () => runShell('analyticscli projects list --format json', {
|
|
503
|
+
env: analyticsToken
|
|
504
|
+
? {
|
|
505
|
+
[analyticsTokenEnv]: analyticsToken,
|
|
506
|
+
ANALYTICSCLI_ACCESS_TOKEN: analyticsToken,
|
|
507
|
+
ANALYTICSCLI_READONLY_TOKEN: analyticsToken,
|
|
508
|
+
}
|
|
509
|
+
: undefined,
|
|
510
|
+
timeoutMs,
|
|
511
|
+
});
|
|
512
|
+
let result = await runCheck();
|
|
513
|
+
let retried = false;
|
|
514
|
+
if (!result.ok && isTransientNetworkFailure(result.stderr || result.stdout)) {
|
|
515
|
+
retried = true;
|
|
516
|
+
await sleep(1_500);
|
|
517
|
+
result = await runCheck();
|
|
518
|
+
}
|
|
519
|
+
if (!result.ok) {
|
|
520
|
+
return {
|
|
521
|
+
ok: false,
|
|
522
|
+
detail: truncate(`${retried ? 'transient network error persisted after retry: ' : ''}${result.stderr || `exit ${result.code}`}`),
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
return {
|
|
526
|
+
ok: true,
|
|
527
|
+
detail: analyticsToken
|
|
528
|
+
? `analyticscli token auth check passed${retried ? ' after retry' : ''} (\`projects list\`)`
|
|
529
|
+
: `analyticscli auth check passed${retried ? ' after retry' : ''} (\`projects list\`)`,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
async function testRevenueCatConnection(revenuecatToken, timeoutMs) {
|
|
533
|
+
if (!revenuecatToken) {
|
|
534
|
+
return {
|
|
535
|
+
ok: false,
|
|
536
|
+
detail: 'missing token',
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
try {
|
|
540
|
+
const response = await fetchWithTimeout('https://api.revenuecat.com/v2/projects?limit=1', {
|
|
541
|
+
method: 'GET',
|
|
542
|
+
headers: {
|
|
543
|
+
Accept: 'application/json',
|
|
544
|
+
Authorization: `Bearer ${revenuecatToken}`,
|
|
545
|
+
},
|
|
546
|
+
}, timeoutMs);
|
|
547
|
+
if (!response.ok) {
|
|
548
|
+
return {
|
|
549
|
+
ok: false,
|
|
550
|
+
detail: `HTTP ${response.status}: ${truncate(response.body)}`,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
return {
|
|
554
|
+
ok: true,
|
|
555
|
+
detail: `HTTP ${response.status}`,
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
catch (error) {
|
|
559
|
+
return {
|
|
560
|
+
ok: false,
|
|
561
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
function describeAnalyticsConnectionFailure(detail, analyticsTokenEnv, hasAnalyticsToken) {
|
|
566
|
+
if (!hasAnalyticsToken) {
|
|
567
|
+
return `AnalyticsCLI needs query access. Run \`node scripts/openclaw-growth-wizard.mjs --connectors analytics\`, create or copy a readonly CLI token in dash.analyticscli.com -> API Keys, and paste it into the local terminal wizard. Raw error: ${detail}`;
|
|
568
|
+
}
|
|
569
|
+
return `AnalyticsCLI connection failed with \`${analyticsTokenEnv}\` set. Verify that the pasted readonly CLI token is current and has project access. Raw error: ${detail}`;
|
|
570
|
+
}
|
|
571
|
+
async function testSentryConnection(sentryToken, timeoutMs, baseUrl = 'https://sentry.io') {
|
|
572
|
+
if (!sentryToken) {
|
|
573
|
+
return {
|
|
574
|
+
ok: false,
|
|
575
|
+
detail: 'missing token',
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
try {
|
|
579
|
+
const response = await fetchWithTimeout(`${String(baseUrl || 'https://sentry.io').replace(/\/$/, '')}/api/0/organizations/`, {
|
|
580
|
+
method: 'GET',
|
|
581
|
+
headers: {
|
|
582
|
+
Accept: 'application/json',
|
|
583
|
+
Authorization: `Bearer ${sentryToken}`,
|
|
584
|
+
},
|
|
585
|
+
}, timeoutMs);
|
|
586
|
+
if (!response.ok) {
|
|
587
|
+
return {
|
|
588
|
+
ok: false,
|
|
589
|
+
detail: `HTTP ${response.status}: ${truncate(response.body)}`,
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
return {
|
|
593
|
+
ok: true,
|
|
594
|
+
detail: `HTTP ${response.status}`,
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
catch (error) {
|
|
598
|
+
return {
|
|
599
|
+
ok: false,
|
|
600
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
function normalizeSentryAccounts(config, sentryTokenEnv) {
|
|
605
|
+
const sentrySource = config?.sources?.sentry;
|
|
606
|
+
const accounts = Array.isArray(sentrySource?.accounts) ? sentrySource.accounts : [];
|
|
607
|
+
if (accounts.length > 0) {
|
|
608
|
+
return accounts.map((account, index) => ({
|
|
609
|
+
key: String(account?.id || account?.key || account?.label || `sentry_${index + 1}`)
|
|
610
|
+
.trim()
|
|
611
|
+
.replace(/[^a-zA-Z0-9._-]+/g, '_'),
|
|
612
|
+
label: String(account?.label || account?.name || account?.id || `Sentry ${index + 1}`).trim(),
|
|
613
|
+
tokenEnv: String(account?.tokenEnv || account?.token_env || account?.secretEnv || sentryTokenEnv).trim(),
|
|
614
|
+
baseUrl: String(account?.baseUrl || account?.base_url || account?.url || 'https://sentry.io').trim(),
|
|
615
|
+
}));
|
|
616
|
+
}
|
|
617
|
+
return [
|
|
618
|
+
{
|
|
619
|
+
key: 'sentry',
|
|
620
|
+
label: 'Sentry',
|
|
621
|
+
tokenEnv: sentryTokenEnv,
|
|
622
|
+
baseUrl: String(process.env.SENTRY_BASE_URL || 'https://sentry.io').trim(),
|
|
623
|
+
},
|
|
624
|
+
];
|
|
625
|
+
}
|
|
626
|
+
async function testGitHubConnection(githubToken, githubRepo, timeoutMs, actionMode) {
|
|
627
|
+
if (!githubToken) {
|
|
628
|
+
return {
|
|
629
|
+
ok: false,
|
|
630
|
+
detail: 'missing token',
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
try {
|
|
634
|
+
const response = await fetchWithTimeout('https://api.github.com/user', {
|
|
635
|
+
method: 'GET',
|
|
636
|
+
headers: {
|
|
637
|
+
Accept: 'application/vnd.github+json',
|
|
638
|
+
Authorization: `Bearer ${githubToken}`,
|
|
639
|
+
},
|
|
640
|
+
}, timeoutMs);
|
|
641
|
+
if (!response.ok) {
|
|
642
|
+
return {
|
|
643
|
+
ok: false,
|
|
644
|
+
detail: `HTTP ${response.status}: ${truncate(response.body)}`,
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
const repo = String(githubRepo || '').trim();
|
|
648
|
+
if (!repo) {
|
|
649
|
+
return {
|
|
650
|
+
ok: false,
|
|
651
|
+
detail: 'project.githubRepo is missing',
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
const repoResponse = await fetchWithTimeout(`https://api.github.com/repos/${repo}`, {
|
|
655
|
+
method: 'GET',
|
|
656
|
+
headers: {
|
|
657
|
+
Accept: 'application/vnd.github+json',
|
|
658
|
+
Authorization: `Bearer ${githubToken}`,
|
|
659
|
+
},
|
|
660
|
+
}, timeoutMs);
|
|
661
|
+
if (!repoResponse.ok) {
|
|
662
|
+
return {
|
|
663
|
+
ok: false,
|
|
664
|
+
detail: `repo access check failed (HTTP ${repoResponse.status}: ${truncate(repoResponse.body)})`,
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
const artifactPath = actionMode === 'pull_request'
|
|
668
|
+
? `pulls?state=all&per_page=1`
|
|
669
|
+
: `issues?state=all&per_page=1`;
|
|
670
|
+
const artifactsResponse = await fetchWithTimeout(`https://api.github.com/repos/${repo}/${artifactPath}`, {
|
|
671
|
+
method: 'GET',
|
|
672
|
+
headers: {
|
|
673
|
+
Accept: 'application/vnd.github+json',
|
|
674
|
+
Authorization: `Bearer ${githubToken}`,
|
|
675
|
+
},
|
|
676
|
+
}, timeoutMs);
|
|
677
|
+
if (!artifactsResponse.ok) {
|
|
678
|
+
return {
|
|
679
|
+
ok: false,
|
|
680
|
+
detail: `${getGitHubActionNoun(actionMode)} API check failed (HTTP ${artifactsResponse.status}: ${truncate(artifactsResponse.body)})`,
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
return {
|
|
684
|
+
ok: true,
|
|
685
|
+
detail: `${getGitHubConnectionSummary(actionMode)} (${getGitHubRequirementText(actionMode)})`,
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
catch (error) {
|
|
689
|
+
return {
|
|
690
|
+
ok: false,
|
|
691
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
function getProjectCommandCwd(config) {
|
|
696
|
+
const repoRoot = String(config?.project?.repoRoot || '').trim();
|
|
697
|
+
return repoRoot ? path.resolve(repoRoot) : process.cwd();
|
|
698
|
+
}
|
|
699
|
+
async function testCommandSourceJson(command, cwd = process.cwd()) {
|
|
700
|
+
let result = await runShell(command, { cwd });
|
|
701
|
+
let retried = false;
|
|
702
|
+
if (!result.ok && isTransientNetworkFailure(result.stderr || result.stdout)) {
|
|
703
|
+
retried = true;
|
|
704
|
+
await sleep(1_500);
|
|
705
|
+
result = await runShell(command, { cwd });
|
|
706
|
+
}
|
|
707
|
+
if (!result.ok) {
|
|
708
|
+
return {
|
|
709
|
+
ok: false,
|
|
710
|
+
detail: truncate(`${retried ? 'transient network error persisted after retry: ' : ''}${result.stderr || `exit ${result.code}`}`),
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
try {
|
|
714
|
+
JSON.parse(result.stdout);
|
|
715
|
+
}
|
|
716
|
+
catch {
|
|
717
|
+
return {
|
|
718
|
+
ok: false,
|
|
719
|
+
detail: 'command succeeded but returned non-JSON output',
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
return {
|
|
723
|
+
ok: true,
|
|
724
|
+
detail: retried ? 'command returned JSON after retry' : 'command returned JSON',
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
function onlyAllows(onlyConnectors, connector) {
|
|
728
|
+
return !Array.isArray(onlyConnectors) || onlyConnectors.length === 0 || onlyConnectors.includes(connector);
|
|
729
|
+
}
|
|
730
|
+
async function runConnectionChecks({ checks, config, timeoutMs, progressJson = false, onlyConnectors = [] }) {
|
|
731
|
+
const tasks = [];
|
|
732
|
+
const analyticsTokenEnv = getSecretName(config, 'analyticsTokenEnv', 'ANALYTICSCLI_ACCESS_TOKEN');
|
|
733
|
+
const revenuecatTokenEnv = getSecretName(config, 'revenuecatTokenEnv', 'REVENUECAT_API_KEY');
|
|
734
|
+
const sentryTokenEnv = getSecretName(config, 'sentryTokenEnv', 'SENTRY_AUTH_TOKEN');
|
|
735
|
+
const feedbackTokenEnv = getSecretName(config, 'feedbackTokenEnv', 'FEEDBACK_API_TOKEN');
|
|
736
|
+
const githubTokenEnv = getSecretName(config, 'githubTokenEnv', 'GITHUB_TOKEN');
|
|
737
|
+
const githubRepo = isConfiguredGitHubRepo(config?.project?.githubRepo)
|
|
738
|
+
? String(config.project.githubRepo).trim()
|
|
739
|
+
: '';
|
|
740
|
+
const actionMode = getActionMode(config);
|
|
741
|
+
const requiresGitHubDelivery = shouldAutoCreateGitHubArtifact(config);
|
|
742
|
+
const commandCwd = getProjectCommandCwd(config);
|
|
743
|
+
const analyticsSource = config.sources?.analytics;
|
|
744
|
+
if (onlyAllows(onlyConnectors, 'analytics')) {
|
|
745
|
+
scheduleProgressGroup(tasks, checks, progressJson, {
|
|
746
|
+
key: 'analytics',
|
|
747
|
+
label: 'AnalyticsCLI',
|
|
748
|
+
detail: 'token auth + readonly query',
|
|
749
|
+
run: async (groupChecks) => {
|
|
750
|
+
if (sourceEnabled(config, 'analytics')) {
|
|
751
|
+
const analyticsToken = process.env.ANALYTICSCLI_ACCESS_TOKEN || process.env[analyticsTokenEnv] || process.env.ANALYTICSCLI_READONLY_TOKEN || '';
|
|
752
|
+
const hasAnalyticsToken = Boolean(analyticsToken);
|
|
753
|
+
const analyticsConnection = await testAnalyticsConnection(analyticsToken, analyticsTokenEnv, timeoutMs);
|
|
754
|
+
addCheck(groupChecks, 'connection:analytics', analyticsConnection.ok, analyticsConnection.ok
|
|
755
|
+
? analyticsConnection.detail
|
|
756
|
+
: describeAnalyticsConnectionFailure(analyticsConnection.detail, analyticsTokenEnv, hasAnalyticsToken), analyticsConnection.ok ? 'pass' : analyticsSource?.mode === 'command' ? 'fail' : 'warn');
|
|
757
|
+
if (analyticsSource?.mode === 'command') {
|
|
758
|
+
const command = String(analyticsSource.command || '').trim();
|
|
759
|
+
if (!command) {
|
|
760
|
+
addCheck(checks, 'connection:analytics-command', false, 'analytics source uses command mode but no command configured');
|
|
761
|
+
}
|
|
762
|
+
else {
|
|
763
|
+
const commandCheck = await testCommandSourceJson(command, commandCwd);
|
|
764
|
+
addCheck(groupChecks, 'connection:analytics-command', commandCheck.ok, commandCheck.ok
|
|
765
|
+
? 'analytics command smoke test passed'
|
|
766
|
+
: `analytics command smoke test failed (${commandCheck.detail})`);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
else {
|
|
771
|
+
addCheck(groupChecks, 'connection:analytics', true, 'source disabled');
|
|
772
|
+
}
|
|
773
|
+
},
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
const revenuecatSource = config.sources?.revenuecat;
|
|
777
|
+
if (onlyAllows(onlyConnectors, 'revenuecat')) {
|
|
778
|
+
scheduleProgressGroup(tasks, checks, progressJson, {
|
|
779
|
+
key: 'revenuecat',
|
|
780
|
+
label: 'RevenueCat',
|
|
781
|
+
detail: 'API key auth + project read',
|
|
782
|
+
run: async (groupChecks) => {
|
|
783
|
+
if (sourceEnabled(config, 'revenuecat')) {
|
|
784
|
+
const token = process.env[revenuecatTokenEnv] || '';
|
|
785
|
+
if (!token) {
|
|
786
|
+
addCheck(groupChecks, `connection:revenuecat`, false, `${revenuecatTokenEnv} missing (required for live RevenueCat API test)`, revenuecatSource?.mode === 'command' ? 'fail' : 'warn');
|
|
787
|
+
}
|
|
788
|
+
else {
|
|
789
|
+
const revenuecatConnection = await testRevenueCatConnection(token, timeoutMs);
|
|
790
|
+
addCheck(groupChecks, 'connection:revenuecat', revenuecatConnection.ok, revenuecatConnection.ok
|
|
791
|
+
? `RevenueCat auth check passed (${revenuecatConnection.detail})`
|
|
792
|
+
: `RevenueCat auth check failed (${revenuecatConnection.detail})`);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
else {
|
|
796
|
+
addCheck(groupChecks, 'connection:revenuecat', true, 'source disabled');
|
|
797
|
+
}
|
|
798
|
+
},
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
const sentrySource = config.sources?.sentry;
|
|
802
|
+
if (onlyAllows(onlyConnectors, 'sentry')) {
|
|
803
|
+
scheduleProgressGroup(tasks, checks, progressJson, {
|
|
804
|
+
key: 'sentry',
|
|
805
|
+
label: 'Sentry / GlitchTip',
|
|
806
|
+
detail: 'token/org API + project discovery',
|
|
807
|
+
run: async (groupChecks) => {
|
|
808
|
+
if (sourceEnabled(config, 'sentry')) {
|
|
809
|
+
const sentryAccounts = normalizeSentryAccounts(config, sentryTokenEnv);
|
|
810
|
+
for (const account of sentryAccounts) {
|
|
811
|
+
const token = process.env[account.tokenEnv] || '';
|
|
812
|
+
const checkName = sentryAccounts.length > 1 ? `connection:sentry:${account.key}` : 'connection:sentry';
|
|
813
|
+
if (!token) {
|
|
814
|
+
addCheck(groupChecks, checkName, false, `${account.tokenEnv} missing (required for live Sentry API test for ${account.label})`, sentrySource?.mode === 'command' ? 'fail' : 'warn');
|
|
815
|
+
continue;
|
|
816
|
+
}
|
|
817
|
+
const sentryConnection = await testSentryConnection(token, timeoutMs, account.baseUrl);
|
|
818
|
+
addCheck(groupChecks, checkName, sentryConnection.ok, sentryConnection.ok
|
|
819
|
+
? `${account.label} auth check passed (${sentryConnection.detail})`
|
|
820
|
+
: `${account.label} auth check failed (${sentryConnection.detail})`);
|
|
821
|
+
}
|
|
822
|
+
if (sentrySource?.mode === 'command') {
|
|
823
|
+
const command = String(sentrySource.command || '').trim();
|
|
824
|
+
if (!command) {
|
|
825
|
+
addCheck(groupChecks, 'connection:sentry-command', false, 'sentry source uses command mode but no command configured');
|
|
826
|
+
}
|
|
827
|
+
else {
|
|
828
|
+
const commandCheck = await testCommandSourceJson(`${command} --limit 1 --max-signals 1 --last 24h`, commandCwd);
|
|
829
|
+
addCheck(groupChecks, 'connection:sentry-command', commandCheck.ok, commandCheck.ok
|
|
830
|
+
? 'Sentry command smoke test passed'
|
|
831
|
+
: `Sentry command smoke test failed (${commandCheck.detail})`);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
else {
|
|
836
|
+
addCheck(groupChecks, 'connection:sentry', true, 'source disabled');
|
|
837
|
+
}
|
|
838
|
+
},
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
const feedbackSource = config.sources?.feedback;
|
|
842
|
+
if (!onlyAllows(onlyConnectors, 'feedback')) {
|
|
843
|
+
// Skip feedback during focused connector checks.
|
|
844
|
+
}
|
|
845
|
+
else if (sourceEnabled(config, 'feedback') && feedbackSource?.mode === 'command') {
|
|
846
|
+
const command = String(feedbackSource.command || '').trim();
|
|
847
|
+
if (!command) {
|
|
848
|
+
addCheck(checks, 'connection:feedback', false, 'feedback source uses command mode but no command configured');
|
|
849
|
+
}
|
|
850
|
+
else {
|
|
851
|
+
const feedbackConnection = await testCommandSourceJson(command, commandCwd);
|
|
852
|
+
addCheck(checks, 'connection:feedback', feedbackConnection.ok, feedbackConnection.ok
|
|
853
|
+
? 'Feedback command smoke test passed'
|
|
854
|
+
: `Feedback command smoke test failed (${feedbackConnection.detail})`);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
else if (sourceEnabled(config, 'feedback')) {
|
|
858
|
+
if (process.env[feedbackTokenEnv]) {
|
|
859
|
+
addCheck(checks, 'connection:feedback', true, 'source in file mode; FEEDBACK_API_TOKEN is present');
|
|
860
|
+
}
|
|
861
|
+
else {
|
|
862
|
+
addCheck(checks, 'connection:feedback', true, 'source in file mode (no direct API smoke test required)');
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
else {
|
|
866
|
+
addCheck(checks, 'connection:feedback', true, 'source disabled');
|
|
867
|
+
}
|
|
868
|
+
for (const extraSource of getAllSourceEntries(config).filter((source) => !source.builtIn)) {
|
|
869
|
+
const serviceKind = classifyServiceKind(extraSource.service || extraSource.key);
|
|
870
|
+
const connectorKind = serviceKind === 'store'
|
|
871
|
+
? 'asc'
|
|
872
|
+
: serviceKind === 'revenue'
|
|
873
|
+
? 'revenuecat'
|
|
874
|
+
: serviceKind === 'crash'
|
|
875
|
+
? 'sentry'
|
|
876
|
+
: serviceKind;
|
|
877
|
+
if (!onlyAllows(onlyConnectors, connectorKind))
|
|
878
|
+
continue;
|
|
879
|
+
const checkName = `connection:${extraSource.key}`;
|
|
880
|
+
if (extraSource.enabled === false) {
|
|
881
|
+
addCheck(checks, checkName, true, 'source disabled');
|
|
882
|
+
continue;
|
|
883
|
+
}
|
|
884
|
+
if (extraSource.mode === 'command') {
|
|
885
|
+
const command = String(extraSource.command || '').trim();
|
|
886
|
+
if (!command) {
|
|
887
|
+
addCheck(checks, checkName, false, 'source uses command mode but no command configured');
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
const smokeCommand = connectorKind === 'asc' && command.includes('export-asc-summary')
|
|
891
|
+
? `${command} --skip-web-analytics --reviews-limit 1 --feedback-limit 1 --max-signals 1`
|
|
892
|
+
: command;
|
|
893
|
+
const commandCheck = await testCommandSourceJson(smokeCommand, commandCwd);
|
|
894
|
+
addCheck(checks, checkName, commandCheck.ok, commandCheck.ok
|
|
895
|
+
? `${extraSource.key} command smoke test passed`
|
|
896
|
+
: `${extraSource.key} command smoke test failed (${commandCheck.detail})`);
|
|
897
|
+
continue;
|
|
898
|
+
}
|
|
899
|
+
if (extraSource.secretEnv) {
|
|
900
|
+
const hasSecret = Boolean(process.env[extraSource.secretEnv]);
|
|
901
|
+
addCheck(checks, checkName, hasSecret || serviceKind === 'feedback', hasSecret
|
|
902
|
+
? `${extraSource.secretEnv} set`
|
|
903
|
+
: serviceKind === 'feedback'
|
|
904
|
+
? 'file mode without direct API test'
|
|
905
|
+
: `${extraSource.secretEnv} not set (required for this extra connector)`, hasSecret || serviceKind === 'feedback' ? 'pass' : 'warn');
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
addCheck(checks, checkName, true, 'file mode (no live API smoke test configured)');
|
|
909
|
+
}
|
|
910
|
+
const githubToken = process.env[githubTokenEnv] || '';
|
|
911
|
+
const githubCheckName = actionMode === 'pull_request' ? 'connection:github-pull-requests' : 'connection:github';
|
|
912
|
+
if (onlyAllows(onlyConnectors, 'github')) {
|
|
913
|
+
scheduleProgressGroup(tasks, checks, progressJson, {
|
|
914
|
+
key: 'github',
|
|
915
|
+
label: 'GitHub',
|
|
916
|
+
detail: githubRepo ? `repo access (${githubRepo})` : 'repo access deferred until repo is known',
|
|
917
|
+
run: async (groupChecks) => {
|
|
918
|
+
if (!requiresGitHubDelivery && (!githubToken || !githubRepo)) {
|
|
919
|
+
addCheck(groupChecks, githubCheckName, true, githubToken
|
|
920
|
+
? 'skipped because project.githubRepo is not configured'
|
|
921
|
+
: 'skipped because GitHub artifact creation is disabled and no GITHUB_TOKEN is configured');
|
|
922
|
+
}
|
|
923
|
+
else if (!githubToken) {
|
|
924
|
+
addCheck(groupChecks, githubCheckName, !requiresGitHubDelivery, `${githubTokenEnv} missing (required; ${getGitHubRequirementText(actionMode)})`, requiresGitHubDelivery ? 'fail' : 'warn');
|
|
925
|
+
}
|
|
926
|
+
else {
|
|
927
|
+
const githubConnection = await testGitHubConnection(githubToken, githubRepo, timeoutMs, actionMode);
|
|
928
|
+
addCheck(groupChecks, githubCheckName, githubConnection.ok, githubConnection.ok
|
|
929
|
+
? `GitHub auth check passed (${githubConnection.detail})`
|
|
930
|
+
: `GitHub auth check failed (${githubConnection.detail})`);
|
|
931
|
+
}
|
|
932
|
+
},
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
await Promise.all(tasks);
|
|
936
|
+
}
|
|
937
|
+
async function main() {
|
|
938
|
+
await loadOpenClawGrowthSecrets();
|
|
939
|
+
const args = parseArgs(process.argv.slice(2));
|
|
940
|
+
const configPath = path.resolve(args.config);
|
|
941
|
+
const checks = [];
|
|
942
|
+
addCheck(checks, 'node-runtime', true, `Node ${process.version}`);
|
|
943
|
+
let config = null;
|
|
944
|
+
try {
|
|
945
|
+
config = await readJson(configPath);
|
|
946
|
+
addCheck(checks, 'config-file', true, `Loaded ${configPath}`);
|
|
947
|
+
}
|
|
948
|
+
catch (error) {
|
|
949
|
+
addCheck(checks, 'config-file', false, `Could not read config at ${configPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
950
|
+
}
|
|
951
|
+
if (config) {
|
|
952
|
+
await applyOpenClawSecretRefs(config);
|
|
953
|
+
emitProgress(args.progressJson, {
|
|
954
|
+
phase: 'start',
|
|
955
|
+
key: 'preflight',
|
|
956
|
+
label: 'Local preflight',
|
|
957
|
+
detail: 'config, dependencies, and source wiring',
|
|
958
|
+
});
|
|
959
|
+
const actionMode = getActionMode(config);
|
|
960
|
+
const requiresGitHubDelivery = shouldAutoCreateGitHubArtifact(config);
|
|
961
|
+
const analyticsEnabled = sourceEnabled(config, 'analytics');
|
|
962
|
+
addCheck(checks, 'source:analytics:required', analyticsEnabled, analyticsEnabled ? 'enabled' : 'analytics source is required and cannot be disabled');
|
|
963
|
+
const analyticscliEnsure = await ensureAnalyticsCliInstalled();
|
|
964
|
+
addCheck(checks, 'dependency:analyticscli', analyticscliEnsure.ok, analyticscliEnsure.detail);
|
|
965
|
+
const githubRepo = isConfiguredGitHubRepo(config.project?.githubRepo)
|
|
966
|
+
? String(config.project.githubRepo).trim()
|
|
967
|
+
: '';
|
|
968
|
+
addCheck(checks, 'project:github-repo', true, githubRepo
|
|
969
|
+
? `configured (${githubRepo})`
|
|
970
|
+
: 'not configured; GitHub repo context/delivery will be inferred later when possible', 'warn');
|
|
971
|
+
const githubTokenEnv = getSecretName(config, 'githubTokenEnv', 'GITHUB_TOKEN');
|
|
972
|
+
const hasGithubToken = Boolean(process.env[githubTokenEnv]);
|
|
973
|
+
addCheck(checks, `secret:${githubTokenEnv}`, hasGithubToken || !requiresGitHubDelivery, hasGithubToken
|
|
974
|
+
? requiresGitHubDelivery
|
|
975
|
+
? `set (required; ${getGitHubRequirementText(actionMode)})`
|
|
976
|
+
: 'set (optional when GitHub delivery is disabled)'
|
|
977
|
+
: requiresGitHubDelivery
|
|
978
|
+
? `missing (required; ${getGitHubRequirementText(actionMode)})`
|
|
979
|
+
: 'optional when GitHub delivery is disabled', requiresGitHubDelivery ? 'fail' : 'warn');
|
|
980
|
+
for (const source of getAllSourceEntries(config)) {
|
|
981
|
+
const sourceName = source.key;
|
|
982
|
+
if (!source || source.enabled === false) {
|
|
983
|
+
addCheck(checks, `source:${sourceName}`, sourceName !== 'analytics', sourceName === 'analytics' ? 'disabled (not allowed)' : 'disabled');
|
|
984
|
+
continue;
|
|
985
|
+
}
|
|
986
|
+
if (source.mode === 'file') {
|
|
987
|
+
const sourcePath = source.path ? path.resolve(String(source.path)) : null;
|
|
988
|
+
if (!sourcePath) {
|
|
989
|
+
addCheck(checks, `source:${sourceName}:file`, false, 'mode=file but no path configured');
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
992
|
+
try {
|
|
993
|
+
await fs.access(sourcePath);
|
|
994
|
+
addCheck(checks, `source:${sourceName}:file`, true, `Found ${sourcePath}`);
|
|
995
|
+
}
|
|
996
|
+
catch {
|
|
997
|
+
addCheck(checks, `source:${sourceName}:file`, false, `Missing file ${sourcePath}`);
|
|
998
|
+
}
|
|
999
|
+
continue;
|
|
1000
|
+
}
|
|
1001
|
+
if (source.mode === 'command') {
|
|
1002
|
+
const command = String(source.command || '').trim();
|
|
1003
|
+
if (!command) {
|
|
1004
|
+
addCheck(checks, `source:${sourceName}:command`, false, 'mode=command but no command configured');
|
|
1005
|
+
continue;
|
|
1006
|
+
}
|
|
1007
|
+
const usesPortableDefault = isPortableCommandDefault(sourceName, command);
|
|
1008
|
+
addCheck(checks, `source:${sourceName}:mode`, usesPortableDefault, usesPortableDefault
|
|
1009
|
+
? 'mode=command configured with built-in portable exporter'
|
|
1010
|
+
: 'mode=command configured (allowed, but file mode is the recommended default)', 'warn');
|
|
1011
|
+
const head = parseCommandHead(command);
|
|
1012
|
+
if (!head) {
|
|
1013
|
+
addCheck(checks, `source:${sourceName}:command`, false, 'Could not parse command head');
|
|
1014
|
+
continue;
|
|
1015
|
+
}
|
|
1016
|
+
const exists = await commandExists(head);
|
|
1017
|
+
addCheck(checks, `source:${sourceName}:command-head`, exists, exists ? `Found command head: ${head}` : `Missing command head: ${head}`);
|
|
1018
|
+
if (sourceName === 'revenuecat') {
|
|
1019
|
+
const revenuecatTokenEnv = getSecretName(config, 'revenuecatTokenEnv', 'REVENUECAT_API_KEY');
|
|
1020
|
+
const hasRevenuecatToken = Boolean(process.env[revenuecatTokenEnv]);
|
|
1021
|
+
addCheck(checks, `secret:${revenuecatTokenEnv}`, hasRevenuecatToken, hasRevenuecatToken ? 'set (required for RevenueCat command mode)' : 'missing (required for RevenueCat command mode)');
|
|
1022
|
+
}
|
|
1023
|
+
if (sourceName === 'sentry') {
|
|
1024
|
+
const sentryTokenEnv = getSecretName(config, 'sentryTokenEnv', 'SENTRY_AUTH_TOKEN');
|
|
1025
|
+
for (const account of normalizeSentryAccounts(config, sentryTokenEnv)) {
|
|
1026
|
+
const hasSentryToken = Boolean(process.env[account.tokenEnv]);
|
|
1027
|
+
addCheck(checks, `secret:${account.tokenEnv}`, hasSentryToken, hasSentryToken
|
|
1028
|
+
? `set (required for ${account.label} Sentry command mode)`
|
|
1029
|
+
: `missing (required for ${account.label} Sentry command mode)`);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
if (!source.builtIn && source.secretEnv) {
|
|
1033
|
+
const hasConnectorToken = Boolean(process.env[source.secretEnv]);
|
|
1034
|
+
addCheck(checks, `secret:${source.secretEnv}`, hasConnectorToken, hasConnectorToken
|
|
1035
|
+
? `set (required for ${sourceName} command mode)`
|
|
1036
|
+
: `missing (required for ${sourceName} command mode)`);
|
|
1037
|
+
}
|
|
1038
|
+
continue;
|
|
1039
|
+
}
|
|
1040
|
+
addCheck(checks, `source:${sourceName}`, false, `Unsupported source mode: ${String(source.mode || 'undefined')}`);
|
|
1041
|
+
}
|
|
1042
|
+
addCheck(checks, actionMode === 'pull_request' ? 'github-pull-request-create' : 'github-issue-create', actionMode === 'pull_request'
|
|
1043
|
+
? config.actions?.autoCreatePullRequests === true
|
|
1044
|
+
: config.actions?.autoCreateIssues === true, actionMode === 'pull_request'
|
|
1045
|
+
? config.actions?.autoCreatePullRequests === true
|
|
1046
|
+
? 'enabled'
|
|
1047
|
+
: 'disabled by default (drafts only; enable explicitly to create GitHub artifacts)'
|
|
1048
|
+
: config.actions?.autoCreateIssues === true
|
|
1049
|
+
? 'enabled'
|
|
1050
|
+
: 'disabled by default (drafts only; enable explicitly to create GitHub artifacts)', (actionMode === 'pull_request'
|
|
1051
|
+
? config.actions?.autoCreatePullRequests === true
|
|
1052
|
+
: config.actions?.autoCreateIssues === true)
|
|
1053
|
+
? 'pass'
|
|
1054
|
+
: 'warn');
|
|
1055
|
+
if (config.charting?.enabled) {
|
|
1056
|
+
const pythonExists = await commandExists('python3');
|
|
1057
|
+
addCheck(checks, 'dependency:python3', pythonExists, pythonExists ? 'python3 found' : 'python3 missing');
|
|
1058
|
+
if (pythonExists) {
|
|
1059
|
+
const matplotlibCheck = await runShell("python3 -c 'import matplotlib'");
|
|
1060
|
+
addCheck(checks, 'dependency:matplotlib', matplotlibCheck.ok, matplotlibCheck.ok ? 'matplotlib import ok' : 'matplotlib missing (install with: python3 -m pip install matplotlib)');
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
else {
|
|
1064
|
+
addCheck(checks, 'charting', true, 'disabled');
|
|
1065
|
+
}
|
|
1066
|
+
if (sourceEnabled(config, 'analytics') && config.sources?.analytics?.mode === 'command') {
|
|
1067
|
+
const analyticsTokenEnv = getSecretName(config, 'analyticsTokenEnv', 'ANALYTICSCLI_ACCESS_TOKEN');
|
|
1068
|
+
const hasAnalyticsToken = Boolean(process.env[analyticsTokenEnv] || process.env.ANALYTICSCLI_ACCESS_TOKEN);
|
|
1069
|
+
addCheck(checks, `secret:${analyticsTokenEnv}`, true, hasAnalyticsToken
|
|
1070
|
+
? 'set'
|
|
1071
|
+
: `not set; run the connector wizard to store AnalyticsCLI query access locally`);
|
|
1072
|
+
}
|
|
1073
|
+
emitProgress(args.progressJson, {
|
|
1074
|
+
phase: 'finish',
|
|
1075
|
+
key: 'preflight',
|
|
1076
|
+
label: 'Local preflight',
|
|
1077
|
+
detail: 'config, dependencies, and source wiring',
|
|
1078
|
+
status: checkSliceStatus(checks, 0),
|
|
1079
|
+
});
|
|
1080
|
+
if (args.testConnections) {
|
|
1081
|
+
await runConnectionChecks({
|
|
1082
|
+
checks,
|
|
1083
|
+
config,
|
|
1084
|
+
progressJson: args.progressJson,
|
|
1085
|
+
timeoutMs: args.timeoutMs,
|
|
1086
|
+
onlyConnectors: args.onlyConnectors,
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
const failCount = checks.filter((check) => check.status === 'fail').length;
|
|
1091
|
+
const warnCount = checks.filter((check) => check.status === 'warn').length;
|
|
1092
|
+
const passCount = checks.filter((check) => check.status === 'pass').length;
|
|
1093
|
+
const result = {
|
|
1094
|
+
ok: failCount === 0,
|
|
1095
|
+
summary: {
|
|
1096
|
+
pass: passCount,
|
|
1097
|
+
warn: warnCount,
|
|
1098
|
+
fail: failCount,
|
|
1099
|
+
},
|
|
1100
|
+
checks,
|
|
1101
|
+
};
|
|
1102
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
1103
|
+
if (!result.ok) {
|
|
1104
|
+
process.exitCode = 1;
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
main().catch((error) => {
|
|
1108
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
1109
|
+
process.exitCode = 1;
|
|
1110
|
+
});
|
|
1111
|
+
//# sourceMappingURL=openclaw-growth-preflight.mjs.map
|