@analyticscli/growth-engineer 0.1.1-preview.3 → 0.1.1-preview.30
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/dist/index.js +52 -3
- package/dist/index.js.map +1 -1
- package/dist/runtime/discord-openclaw-bridge.mjs +489 -0
- package/dist/runtime/export-asc-summary.mjs +255 -104
- package/dist/runtime/export-asc-summary.mjs.map +1 -1
- package/dist/runtime/export-paddle-summary.mjs +73 -19
- package/dist/runtime/export-paddle-summary.mjs.map +1 -1
- package/dist/runtime/openclaw-exporters-lib.d.mts +79 -2
- package/dist/runtime/openclaw-exporters-lib.mjs +231 -68
- package/dist/runtime/openclaw-exporters-lib.mjs.map +1 -1
- package/dist/runtime/openclaw-growth-preflight.mjs +84 -20
- package/dist/runtime/openclaw-growth-preflight.mjs.map +1 -1
- package/dist/runtime/openclaw-growth-runner.mjs +154 -17
- package/dist/runtime/openclaw-growth-runner.mjs.map +1 -1
- package/dist/runtime/openclaw-growth-shared.mjs +2 -0
- package/dist/runtime/openclaw-growth-shared.mjs.map +1 -1
- package/dist/runtime/openclaw-growth-start.mjs +170 -33
- package/dist/runtime/openclaw-growth-start.mjs.map +1 -1
- package/dist/runtime/openclaw-growth-status.mjs +183 -2
- package/dist/runtime/openclaw-growth-status.mjs.map +1 -1
- package/dist/runtime/openclaw-growth-wizard.mjs +637 -183
- package/dist/runtime/openclaw-growth-wizard.mjs.map +1 -1
- package/package.json +2 -2
|
@@ -16,6 +16,7 @@ const ENABLE_ISOLATED_SECRET_RUNNER_WIZARD = false;
|
|
|
16
16
|
const DEFAULT_GROWTH_INTERVAL_MINUTES = 90;
|
|
17
17
|
const DEFAULT_CONNECTOR_HEALTH_INTERVAL_MINUTES = 360;
|
|
18
18
|
const DEFAULT_SCHEDULER_PROOF_PATH = 'data/openclaw-growth-engineer/runtime/scheduler-proof.jsonl';
|
|
19
|
+
const DELETE_SECRET = '__OPENCLAW_DELETE_SECRET__';
|
|
19
20
|
const GROWTH_ENGINEER_PACKAGE_SPEC = process.env.OPENCLAW_GROWTH_ENGINEER_PACKAGE || '@analyticscli/growth-engineer@preview';
|
|
20
21
|
const RUNTIME_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
21
22
|
const ACCOUNT_SIGNAL_CONNECTOR_KEYS = [
|
|
@@ -84,7 +85,7 @@ const CONNECTOR_DEFINITIONS = [
|
|
|
84
85
|
key: 'paddle',
|
|
85
86
|
label: 'Paddle Billing metrics',
|
|
86
87
|
summary: 'Read web checkout, revenue, MRR, refunds, chargebacks, and active subscriber metrics.',
|
|
87
|
-
needs: 'A Paddle API key
|
|
88
|
+
needs: 'A scoped Paddle API key for the live account with metrics.read permission.',
|
|
88
89
|
},
|
|
89
90
|
{
|
|
90
91
|
key: 'seo',
|
|
@@ -108,7 +109,7 @@ const CONNECTOR_DEFINITIONS = [
|
|
|
108
109
|
key: 'asc',
|
|
109
110
|
label: 'ASC / App Store Connect CLI',
|
|
110
111
|
summary: 'Read App Store analytics, reviews/ratings, builds/TestFlight/release context, subscriptions, purchases, and crash totals.',
|
|
111
|
-
needs: '
|
|
112
|
+
needs: 'Two App Store Connect API keys: a Sales and Reports or Finance key for ongoing use, plus a temporary Admin key for one-time analytics bootstrap.',
|
|
112
113
|
},
|
|
113
114
|
{
|
|
114
115
|
key: 'stripe',
|
|
@@ -906,7 +907,9 @@ function sourceCommandNeedsActiveConfig(sourceName, command) {
|
|
|
906
907
|
const value = String(command || '').toLowerCase();
|
|
907
908
|
return (normalized === 'sentry' ||
|
|
908
909
|
normalized === 'glitchtip' ||
|
|
910
|
+
normalized === 'paddle' ||
|
|
909
911
|
normalized === 'coolify' ||
|
|
912
|
+
value.includes('export-paddle-summary') ||
|
|
910
913
|
value.includes('export-sentry-summary') ||
|
|
911
914
|
value.includes('export-coolify-summary') ||
|
|
912
915
|
value.includes('exporters coolify-summary'));
|
|
@@ -1646,9 +1649,88 @@ async function connectorKeysForHealthCheck(configPath) {
|
|
|
1646
1649
|
configured.add(key);
|
|
1647
1650
|
return orderConnectors([...configured]);
|
|
1648
1651
|
}
|
|
1652
|
+
function connectorKeyFromRunnerHealthKey(key) {
|
|
1653
|
+
const normalized = String(key || '').trim();
|
|
1654
|
+
if (normalized === 'analyticscli')
|
|
1655
|
+
return 'analytics';
|
|
1656
|
+
if (normalized === 'appStoreConnect')
|
|
1657
|
+
return 'asc';
|
|
1658
|
+
const connector = normalizeConnectorProgressKey(normalized);
|
|
1659
|
+
return connector || null;
|
|
1660
|
+
}
|
|
1661
|
+
function activeIncidentStatusLabel(status) {
|
|
1662
|
+
const normalized = String(status || '').trim();
|
|
1663
|
+
return normalized || 'blocked';
|
|
1664
|
+
}
|
|
1665
|
+
async function readActiveConnectorIncidents(configPath) {
|
|
1666
|
+
const statePath = deriveStatePathFromConfigPath(configPath);
|
|
1667
|
+
const state = await readJsonIfPresent(statePath).catch(() => null);
|
|
1668
|
+
const healthState = state?.connectorHealth;
|
|
1669
|
+
if (!healthState ||
|
|
1670
|
+
healthState.lastStatusOk !== false ||
|
|
1671
|
+
!healthState.activeIncidentFingerprint) {
|
|
1672
|
+
return {};
|
|
1673
|
+
}
|
|
1674
|
+
const alertJsonPath = healthState.lastAlertJsonPath
|
|
1675
|
+
? path.resolve(String(healthState.lastAlertJsonPath))
|
|
1676
|
+
: path.resolve(path.dirname(statePath), 'runtime/connector-health/latest.json');
|
|
1677
|
+
const alertJson = await readJsonIfPresent(alertJsonPath).catch(() => null);
|
|
1678
|
+
const unhealthyConnectors = Array.isArray(alertJson?.unhealthyConnectors)
|
|
1679
|
+
? alertJson.unhealthyConnectors
|
|
1680
|
+
: [];
|
|
1681
|
+
const incidents = {};
|
|
1682
|
+
for (const entry of unhealthyConnectors) {
|
|
1683
|
+
const key = connectorKeyFromRunnerHealthKey(entry?.key);
|
|
1684
|
+
if (!key)
|
|
1685
|
+
continue;
|
|
1686
|
+
incidents[key] = {
|
|
1687
|
+
...entry,
|
|
1688
|
+
status: activeIncidentStatusLabel(entry?.status),
|
|
1689
|
+
detail: String(entry?.detail || 'Runner still has an active connector-health incident').trim(),
|
|
1690
|
+
activeRunnerIncident: true,
|
|
1691
|
+
activeIncidentFingerprint: healthState.activeIncidentFingerprint,
|
|
1692
|
+
lastCheckedAt: healthState.lastCheckedAt || null,
|
|
1693
|
+
};
|
|
1694
|
+
}
|
|
1695
|
+
return incidents;
|
|
1696
|
+
}
|
|
1697
|
+
function mergeActiveConnectorIncidents(healthByConnector, activeIncidents) {
|
|
1698
|
+
if (!activeIncidents || Object.keys(activeIncidents).length === 0) {
|
|
1699
|
+
return healthByConnector;
|
|
1700
|
+
}
|
|
1701
|
+
return Object.fromEntries(CONNECTOR_KEYS.map((key) => {
|
|
1702
|
+
const liveHealth = getConnectorHealth(key, healthByConnector);
|
|
1703
|
+
const incident = activeIncidents[key];
|
|
1704
|
+
if (!incident)
|
|
1705
|
+
return [key, liveHealth];
|
|
1706
|
+
if (liveHealth.status === 'connected') {
|
|
1707
|
+
return [
|
|
1708
|
+
key,
|
|
1709
|
+
{
|
|
1710
|
+
...liveHealth,
|
|
1711
|
+
status: 'partial',
|
|
1712
|
+
detail: `Live wizard check passed, but the runner still has an active ${incident.status} incident; run the connector health job once to record recovery.`,
|
|
1713
|
+
activeRunnerIncident: true,
|
|
1714
|
+
activeIncidentFingerprint: incident.activeIncidentFingerprint,
|
|
1715
|
+
lastCheckedAt: incident.lastCheckedAt,
|
|
1716
|
+
},
|
|
1717
|
+
];
|
|
1718
|
+
}
|
|
1719
|
+
return [
|
|
1720
|
+
key,
|
|
1721
|
+
{
|
|
1722
|
+
...liveHealth,
|
|
1723
|
+
...incident,
|
|
1724
|
+
status: incident.status || liveHealth.status || 'blocked',
|
|
1725
|
+
detail: incident.detail || liveHealth.detail,
|
|
1726
|
+
},
|
|
1727
|
+
];
|
|
1728
|
+
}));
|
|
1729
|
+
}
|
|
1649
1730
|
async function getConnectorPickerHealth(configPath, onProgress = () => { }, onlyConnectors = []) {
|
|
1731
|
+
const activeIncidents = await readActiveConnectorIncidents(configPath);
|
|
1650
1732
|
if (!(await fileExists(configPath))) {
|
|
1651
|
-
|
|
1733
|
+
const fallbackHealth = Object.fromEntries(CONNECTOR_KEYS.map((key) => [
|
|
1652
1734
|
key,
|
|
1653
1735
|
{
|
|
1654
1736
|
status: isConnectorLocallyConfigured(key) ? 'unknown' : 'not_connected',
|
|
@@ -1657,9 +1739,11 @@ async function getConnectorPickerHealth(configPath, onProgress = () => { }, only
|
|
|
1657
1739
|
: '',
|
|
1658
1740
|
},
|
|
1659
1741
|
]));
|
|
1742
|
+
return mergeActiveConnectorIncidents(fallbackHealth, activeIncidents);
|
|
1660
1743
|
}
|
|
1661
1744
|
if (onlyConnectors.length === 0) {
|
|
1662
|
-
|
|
1745
|
+
const fallbackHealth = Object.fromEntries(CONNECTOR_KEYS.map((key) => [key, getConnectorHealth(key, {})]));
|
|
1746
|
+
return mergeActiveConnectorIncidents(fallbackHealth, activeIncidents);
|
|
1663
1747
|
}
|
|
1664
1748
|
const onlyArg = ` --only-connectors ${quote(orderConnectors(onlyConnectors).join(','))}`;
|
|
1665
1749
|
const result = await runCommandCaptureWithProgress(`${nodeRuntimeScriptCommand('openclaw-growth-status.mjs')} --config ${quote(configPath)} --json --progress-json${onlyArg}`, onProgress);
|
|
@@ -1675,7 +1759,8 @@ async function getConnectorPickerHealth(configPath, onProgress = () => { }, only
|
|
|
1675
1759
|
coolify: connectors.coolify,
|
|
1676
1760
|
asc: connectors.appStoreConnect,
|
|
1677
1761
|
};
|
|
1678
|
-
|
|
1762
|
+
const liveHealth = Object.fromEntries(CONNECTOR_KEYS.map((key) => [key, getConnectorHealth(key, healthByConnector)]));
|
|
1763
|
+
return mergeActiveConnectorIncidents(liveHealth, activeIncidents);
|
|
1679
1764
|
}
|
|
1680
1765
|
function renderConnectorPicker(cursorIndex, selected, required, healthByConnector = {}, warning = '', copy = {}) {
|
|
1681
1766
|
process.stdout.write('\x1b[2J\x1b[H');
|
|
@@ -1995,13 +2080,60 @@ function parseJsonFromStdout(stdout) {
|
|
|
1995
2080
|
const starts = [firstBrace, firstBracket].filter((index) => index >= 0);
|
|
1996
2081
|
if (starts.length === 0)
|
|
1997
2082
|
return null;
|
|
2083
|
+
const start = Math.min(...starts);
|
|
2084
|
+
const jsonText = extractFirstJsonValue(raw, start);
|
|
2085
|
+
if (!jsonText)
|
|
2086
|
+
return null;
|
|
1998
2087
|
try {
|
|
1999
|
-
return JSON.parse(
|
|
2088
|
+
return JSON.parse(jsonText);
|
|
2000
2089
|
}
|
|
2001
2090
|
catch {
|
|
2002
2091
|
return null;
|
|
2003
2092
|
}
|
|
2004
2093
|
}
|
|
2094
|
+
function extractFirstJsonValue(raw, start) {
|
|
2095
|
+
const open = raw[start];
|
|
2096
|
+
const close = open === '{' ? '}' : open === '[' ? ']' : '';
|
|
2097
|
+
if (!close)
|
|
2098
|
+
return '';
|
|
2099
|
+
let depth = 0;
|
|
2100
|
+
let inString = false;
|
|
2101
|
+
let escaped = false;
|
|
2102
|
+
for (let index = start; index < raw.length; index += 1) {
|
|
2103
|
+
const char = raw[index];
|
|
2104
|
+
if (inString) {
|
|
2105
|
+
if (escaped) {
|
|
2106
|
+
escaped = false;
|
|
2107
|
+
}
|
|
2108
|
+
else if (char === '\\') {
|
|
2109
|
+
escaped = true;
|
|
2110
|
+
}
|
|
2111
|
+
else if (char === '"') {
|
|
2112
|
+
inString = false;
|
|
2113
|
+
}
|
|
2114
|
+
continue;
|
|
2115
|
+
}
|
|
2116
|
+
if (char === '"') {
|
|
2117
|
+
inString = true;
|
|
2118
|
+
continue;
|
|
2119
|
+
}
|
|
2120
|
+
if (char === open)
|
|
2121
|
+
depth += 1;
|
|
2122
|
+
if (char === close) {
|
|
2123
|
+
depth -= 1;
|
|
2124
|
+
if (depth === 0)
|
|
2125
|
+
return raw.slice(start, index + 1);
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
return '';
|
|
2129
|
+
}
|
|
2130
|
+
function stripProgressOutput(value) {
|
|
2131
|
+
return String(value || '')
|
|
2132
|
+
.split(/\r?\n/)
|
|
2133
|
+
.filter((line) => !line.startsWith('OPENCLAW_PROGRESS '))
|
|
2134
|
+
.join('\n')
|
|
2135
|
+
.trim();
|
|
2136
|
+
}
|
|
2005
2137
|
function clearTerminal() {
|
|
2006
2138
|
if (process.stdout.isTTY) {
|
|
2007
2139
|
process.stdout.write('\x1b[2J\x1b[H');
|
|
@@ -2042,6 +2174,21 @@ function getPassingConnectorKeys(payload, failedConnectors = new Set()) {
|
|
|
2042
2174
|
}
|
|
2043
2175
|
function summarizeFailureReason(detail) {
|
|
2044
2176
|
const text = String(detail || '').replace(/\s+/g, ' ').trim();
|
|
2177
|
+
if (/ASC Reports key auth failed: .*\.p8 key could not be parsed/i.test(text)) {
|
|
2178
|
+
return 'ASC Reports key auth failed: the .p8 key could not be parsed';
|
|
2179
|
+
}
|
|
2180
|
+
if (/ASC Setup Admin key auth failed: .*\.p8 key could not be parsed/i.test(text)) {
|
|
2181
|
+
return 'ASC Setup Admin key auth failed: the .p8 key could not be parsed';
|
|
2182
|
+
}
|
|
2183
|
+
if (/ASC Reports key auth failed: .*\.p8 file permissions are too open/i.test(text)) {
|
|
2184
|
+
return 'ASC Reports key auth failed: .p8 file permissions are too open';
|
|
2185
|
+
}
|
|
2186
|
+
if (/ASC Setup Admin key auth failed: .*\.p8 file permissions are too open/i.test(text)) {
|
|
2187
|
+
return 'ASC Setup Admin key auth failed: .p8 file permissions are too open';
|
|
2188
|
+
}
|
|
2189
|
+
if (/ASC .*\.p8 private key is invalid|invalid private key|failed to parse|sequence truncated|malformed|asn1/i.test(text)) {
|
|
2190
|
+
return 'ASC auth failed: the .p8 key could not be parsed';
|
|
2191
|
+
}
|
|
2045
2192
|
if (/token has been revoked/i.test(text))
|
|
2046
2193
|
return 'token has been revoked';
|
|
2047
2194
|
if (/unauthorized|UNAUTHORIZED/i.test(text))
|
|
@@ -2075,7 +2222,7 @@ function summarizeFailureFix(connector, blockers) {
|
|
|
2075
2222
|
return 'Paste a RevenueCat v2 secret API key with read-only project permissions, then rerun setup.';
|
|
2076
2223
|
}
|
|
2077
2224
|
if (connector === 'paddle') {
|
|
2078
|
-
return 'Paste a Paddle API key with metrics.read permission
|
|
2225
|
+
return 'Paste a live Paddle API key from Developer Tools > Authentication v2 with metrics.read permission, then rerun setup.';
|
|
2079
2226
|
}
|
|
2080
2227
|
if (connector === 'seo') {
|
|
2081
2228
|
return 'Configure Search Console read access. Leave GSC_SITE_URL empty to scan all verified properties in the account, or set it only when you intentionally want one property.';
|
|
@@ -2084,6 +2231,21 @@ function summarizeFailureFix(connector, blockers) {
|
|
|
2084
2231
|
return 'Paste a Coolify base URL and read-only API token from Keys & Tokens / API tokens, then rerun setup.';
|
|
2085
2232
|
}
|
|
2086
2233
|
if (connector === 'asc') {
|
|
2234
|
+
if (/invalid value: ['"]?state|parameter has an invalid value: ['"]?state|--state/i.test(combined)) {
|
|
2235
|
+
return 'Update Growth Engineer and rerun ASC setup. Setup no longer uses the flaky ASC analytics request state filter.';
|
|
2236
|
+
}
|
|
2237
|
+
if (/file permissions are too open|too permissive|chmod 600/i.test(combined)) {
|
|
2238
|
+
return 'Rerun ASC setup. The wizard saves a secure local copy of AuthKey_<KEY_ID>.p8 with chmod 600 before testing.';
|
|
2239
|
+
}
|
|
2240
|
+
if (/Reports key auth failed|Reports key/i.test(combined) && /private key|could not be parsed|failed to parse|asn1/i.test(combined)) {
|
|
2241
|
+
return 'Use the original downloaded AuthKey_<KEY_ID>.p8 file for the Reports key. The wizard bypasses old asc keychain/config credentials during setup.';
|
|
2242
|
+
}
|
|
2243
|
+
if (/Setup Admin key auth failed|Admin key/i.test(combined) && /private key|could not be parsed|failed to parse|asn1/i.test(combined)) {
|
|
2244
|
+
return 'Use the original downloaded AuthKey_<KEY_ID>.p8 file for the Setup Admin key. This key is temporary and should have the Admin role.';
|
|
2245
|
+
}
|
|
2246
|
+
if (/invalid|truncated|malformed|private key|could not be parsed|failed to parse|asn1/i.test(combined)) {
|
|
2247
|
+
return 'Use the original downloaded AuthKey_<KEY_ID>.p8 for the Reports key. Old pasted ASC_PRIVATE_KEY values are removed when you choose a file path.';
|
|
2248
|
+
}
|
|
2087
2249
|
return 'Rerun ASC setup and verify ASC credentials, key role access, and `asc apps list --output json`.';
|
|
2088
2250
|
}
|
|
2089
2251
|
if (isAccountSignalConnector(connector)) {
|
|
@@ -2132,21 +2294,15 @@ function printConciseSetupBlockers(payload, command, options = {}) {
|
|
|
2132
2294
|
process.stdout.write(`Live checks passed: ${passingConnectors.map(connectorTitle).join(', ')}.\n`);
|
|
2133
2295
|
}
|
|
2134
2296
|
if (groups.size > 0) {
|
|
2135
|
-
process.stdout.write('\nNeeds
|
|
2297
|
+
process.stdout.write('\nNeeds attention:\n');
|
|
2136
2298
|
for (const [connector, connectorBlockers] of groups.entries()) {
|
|
2137
2299
|
const primary = connectorBlockers[0] || {};
|
|
2138
|
-
const reasons = [
|
|
2139
|
-
...new Set(connectorBlockers
|
|
2140
|
-
.map((blocker) => summarizeFailureReason(blocker.detail || blocker.check))
|
|
2141
|
-
.filter(Boolean)),
|
|
2142
|
-
];
|
|
2143
2300
|
process.stdout.write(`- ${connectorTitle(connector)}: ${summarizeFailureReason(primary.detail || primary.check)}\n`);
|
|
2144
|
-
process.stdout.write(` Why: ${reasons.join('; ')}.\n`);
|
|
2145
2301
|
process.stdout.write(` Fix: ${summarizeFailureFix(connector, connectorBlockers)}\n`);
|
|
2146
2302
|
}
|
|
2147
2303
|
}
|
|
2148
2304
|
printDeferredSetupNotes(blockers, focusConnectors);
|
|
2149
|
-
if (groups.size > 0 || !options.hideRerunWhenClean) {
|
|
2305
|
+
if (!options.hideRerun && (groups.size > 0 || !options.hideRerunWhenClean)) {
|
|
2150
2306
|
process.stdout.write(`\nRerun: ${command}\n`);
|
|
2151
2307
|
}
|
|
2152
2308
|
}
|
|
@@ -2202,11 +2358,10 @@ function printSetupFailure({ result, payload, command }) {
|
|
|
2202
2358
|
}
|
|
2203
2359
|
const reason = result.code === null ? 'setup command did not report an exit code' : `setup command exited with code ${result.code}`;
|
|
2204
2360
|
process.stdout.write(`Reason: ${reason}.\n`);
|
|
2205
|
-
const output = truncate(result.stderr || result.stdout);
|
|
2361
|
+
const output = truncate(stripProgressOutput(result.stderr) || stripProgressOutput(result.stdout));
|
|
2206
2362
|
if (output) {
|
|
2207
2363
|
process.stdout.write(`Details: ${output}\n`);
|
|
2208
2364
|
}
|
|
2209
|
-
process.stdout.write(`Run manually for full output: ${command}\n`);
|
|
2210
2365
|
}
|
|
2211
2366
|
function printSetupSuccess(payload) {
|
|
2212
2367
|
process.stdout.write('\nSUCCESS: Connector setup finished.\n');
|
|
@@ -2291,9 +2446,9 @@ function isDeferredGitHubFailure(failure) {
|
|
|
2291
2446
|
return (name === 'project:github-repo' ||
|
|
2292
2447
|
(name === 'connection:github' && /project\.githubRepo|repo is missing|repo is not configured/i.test(detail)));
|
|
2293
2448
|
}
|
|
2294
|
-
function healthStatusLabel(status) {
|
|
2449
|
+
function healthStatusLabel(status, spinner = '') {
|
|
2295
2450
|
if (status === 'running')
|
|
2296
|
-
return 'running';
|
|
2451
|
+
return spinner ? `running ${spinner}` : 'running';
|
|
2297
2452
|
if (status === 'pass')
|
|
2298
2453
|
return 'done';
|
|
2299
2454
|
if (status === 'warn')
|
|
@@ -2302,18 +2457,27 @@ function healthStatusLabel(status) {
|
|
|
2302
2457
|
return 'needs attention';
|
|
2303
2458
|
if (status === 'deferred')
|
|
2304
2459
|
return 'deferred';
|
|
2305
|
-
return 'pending';
|
|
2460
|
+
return spinner ? `pending ${spinner}` : 'pending';
|
|
2306
2461
|
}
|
|
2307
|
-
function renderHealthProgress(items, message = 'Live checks running...', title = 'Health check') {
|
|
2462
|
+
function renderHealthProgress(items, message = 'Live checks running...', title = 'Health check', options = {}) {
|
|
2308
2463
|
if (process.stdout.isTTY)
|
|
2309
2464
|
clearTerminal();
|
|
2310
|
-
const
|
|
2465
|
+
const final = Boolean(options.final);
|
|
2466
|
+
const visibleItems = final
|
|
2467
|
+
? items.filter((item) => !['pending', 'running'].includes(String(item.status || '')) && item.key !== 'finalize')
|
|
2468
|
+
: items;
|
|
2469
|
+
const finished = visibleItems.filter((item) => !['pending', 'running'].includes(String(item.status || ''))).length;
|
|
2311
2470
|
process.stdout.write(`${title}\n`);
|
|
2312
2471
|
process.stdout.write('------------\n');
|
|
2313
2472
|
process.stdout.write(`${message}\n\n`);
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2473
|
+
if (final) {
|
|
2474
|
+
process.stdout.write('');
|
|
2475
|
+
}
|
|
2476
|
+
else {
|
|
2477
|
+
process.stdout.write(`${finished}/${visibleItems.length} checks finished.\n\n`);
|
|
2478
|
+
}
|
|
2479
|
+
for (const item of visibleItems) {
|
|
2480
|
+
process.stdout.write(`[${healthStatusLabel(item.status, options.spinner || '')}] ${item.label}: ${item.detail}\n`);
|
|
2317
2481
|
}
|
|
2318
2482
|
}
|
|
2319
2483
|
function updateHealthProgress(items, event) {
|
|
@@ -2424,22 +2588,41 @@ function updateProgressItem(items, key, status, detail) {
|
|
|
2424
2588
|
}
|
|
2425
2589
|
async function runSetupCommandWithProgress(command, env, selected, message) {
|
|
2426
2590
|
const plan = buildSetupTestProgressPlan(selected);
|
|
2427
|
-
|
|
2591
|
+
const spinnerFrames = ['-', '\\', '|', '/'];
|
|
2592
|
+
let spinnerIndex = 0;
|
|
2593
|
+
let currentMessage = message;
|
|
2594
|
+
const render = (nextMessage = currentMessage, options = {}) => {
|
|
2595
|
+
currentMessage = nextMessage;
|
|
2596
|
+
renderHealthProgress(plan, currentMessage, 'Connector setup test', {
|
|
2597
|
+
...options,
|
|
2598
|
+
spinner: spinnerFrames[spinnerIndex++ % spinnerFrames.length],
|
|
2599
|
+
});
|
|
2600
|
+
};
|
|
2601
|
+
render(message);
|
|
2602
|
+
const spinnerInterval = process.stdout.isTTY
|
|
2603
|
+
? setInterval(() => render(currentMessage), 800)
|
|
2604
|
+
: null;
|
|
2428
2605
|
const progressCommand = command.includes('--progress-json') ? command : `${command} --progress-json`;
|
|
2429
2606
|
const result = await runCommandCaptureWithProgress(progressCommand, (event) => {
|
|
2430
|
-
if (updateHealthProgress(plan, event))
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
const message = primaryFinished
|
|
2436
|
-
? 'Checks finished. Finalizing result; do not close this terminal yet.'
|
|
2437
|
-
: 'Connector setup test is still running. Do not close this terminal yet.';
|
|
2438
|
-
renderHealthProgress(plan, message, 'Connector setup test');
|
|
2607
|
+
if (!updateHealthProgress(plan, event))
|
|
2608
|
+
return;
|
|
2609
|
+
const primaryFinished = primaryProgressItemsFinished(plan);
|
|
2610
|
+
if (primaryFinished) {
|
|
2611
|
+
updateProgressItem(plan, 'finalize', 'running', 'finishing');
|
|
2439
2612
|
}
|
|
2440
|
-
|
|
2613
|
+
render(primaryFinished ? 'Finishing setup test...' : 'Testing connector setup...');
|
|
2614
|
+
}, { env, timeoutMs: 180_000 }).finally(() => {
|
|
2615
|
+
if (spinnerInterval)
|
|
2616
|
+
clearInterval(spinnerInterval);
|
|
2617
|
+
});
|
|
2618
|
+
const payload = parseJsonFromStdout(result.stdout);
|
|
2619
|
+
if (Array.isArray(payload?.blockers) && payload.blockers.length > 0) {
|
|
2620
|
+
if (process.stdout.isTTY)
|
|
2621
|
+
clearTerminal();
|
|
2622
|
+
return result;
|
|
2623
|
+
}
|
|
2441
2624
|
updateProgressItem(plan, 'finalize', 'pass', 'result received');
|
|
2442
|
-
renderHealthProgress(plan, 'Connector setup test finished.', 'Connector setup test');
|
|
2625
|
+
renderHealthProgress(plan, 'Connector setup test finished.', 'Connector setup test', { final: true });
|
|
2443
2626
|
return result;
|
|
2444
2627
|
}
|
|
2445
2628
|
async function saveSecretsImmediately(secrets) {
|
|
@@ -2447,19 +2630,19 @@ async function saveSecretsImmediately(secrets) {
|
|
|
2447
2630
|
return false;
|
|
2448
2631
|
const secretsFile = resolveSecretsFile();
|
|
2449
2632
|
await writeSecretsFile(secretsFile, secrets);
|
|
2450
|
-
|
|
2633
|
+
applySecretsToProcessEnv(secrets);
|
|
2451
2634
|
process.stdout.write(`Saved local secrets to ${secretsFile} with chmod 600.\n`);
|
|
2452
2635
|
return true;
|
|
2453
2636
|
}
|
|
2454
|
-
async function runImmediateConnectorHealthCheck({ rl, configPath, connector, secrets, sentryAccounts = [], }) {
|
|
2637
|
+
async function runImmediateConnectorHealthCheck({ rl, configPath, connector, secrets, runtimeEnv = {}, sentryAccounts = [], paddleAccounts = [], }) {
|
|
2455
2638
|
if (connector === 'sentry' && sentryAccounts.length > 0) {
|
|
2456
2639
|
await upsertSentryAccountsConfig(configPath, sentryAccounts);
|
|
2457
2640
|
}
|
|
2641
|
+
if (connector === 'paddle' && paddleAccounts.length > 0) {
|
|
2642
|
+
await upsertPaddleAccountsConfig(configPath, paddleAccounts);
|
|
2643
|
+
}
|
|
2458
2644
|
await saveSecretsImmediately(secrets);
|
|
2459
|
-
const env = {
|
|
2460
|
-
...process.env,
|
|
2461
|
-
...secrets,
|
|
2462
|
-
};
|
|
2645
|
+
const env = mergeRuntimeEnv(secrets, connector === 'asc' ? { ASC_BYPASS_KEYCHAIN: '1' } : {}, runtimeEnv);
|
|
2463
2646
|
const command = `${nodeRuntimeScriptCommand('openclaw-growth-start.mjs')} --config ${quote(configPath)} --setup-only --connectors ${quote(connector)} --only-connectors ${quote(connector)}`;
|
|
2464
2647
|
let result = await runSetupCommandWithProgress(command, env, [connector], `Checking ${connectorLabel(connector)} immediately after setup...`);
|
|
2465
2648
|
let payload = parseJsonFromStdout(result.stdout);
|
|
@@ -2468,6 +2651,7 @@ async function runImmediateConnectorHealthCheck({ rl, configPath, connector, sec
|
|
|
2468
2651
|
printConciseSetupBlockers(payload, command, {
|
|
2469
2652
|
focusConnectors: [connector],
|
|
2470
2653
|
hideRerunWhenClean: true,
|
|
2654
|
+
hideRerun: true,
|
|
2471
2655
|
});
|
|
2472
2656
|
const retry = await askYesNo(rl, `Re-enter ${connectorLabel(connector)} configuration now?`, true);
|
|
2473
2657
|
return { ok: false, retry, result, payload };
|
|
@@ -2599,12 +2783,26 @@ function resolveSecretsFile() {
|
|
|
2599
2783
|
return path.join(process.env.HOME, '.config', 'openclaw-growth', 'secrets.env');
|
|
2600
2784
|
return path.resolve('.openclaw-growth-secrets.env');
|
|
2601
2785
|
}
|
|
2602
|
-
function resolveAscPrivateKeyPath(keyId) {
|
|
2786
|
+
function resolveAscPrivateKeyPath(keyId, suffix = '') {
|
|
2603
2787
|
const safeKeyId = (keyId || 'OPENCLAW').trim().replace(/[^a-zA-Z0-9_-]/g, '_') || 'OPENCLAW';
|
|
2604
2788
|
const baseDir = process.env.HOME
|
|
2605
2789
|
? path.join(process.env.HOME, '.config', 'openclaw-growth')
|
|
2606
2790
|
: path.resolve('.openclaw-growth');
|
|
2607
|
-
return path.join(baseDir, `AuthKey_${safeKeyId}.p8`);
|
|
2791
|
+
return path.join(baseDir, `AuthKey_${safeKeyId}${suffix}.p8`);
|
|
2792
|
+
}
|
|
2793
|
+
function inferAscKeyIdFromPrivateKeyPath(filePath) {
|
|
2794
|
+
const fileName = path.basename(String(filePath || '').trim());
|
|
2795
|
+
const match = fileName.match(/^AuthKey_([A-Za-z0-9]+)\.p8$/);
|
|
2796
|
+
return match?.[1] || '';
|
|
2797
|
+
}
|
|
2798
|
+
async function copyAscPrivateKeyToSecurePath(sourcePath, keyId, suffix = '') {
|
|
2799
|
+
const destinationPath = resolveAscPrivateKeyPath(keyId, suffix);
|
|
2800
|
+
await fs.mkdir(path.dirname(destinationPath), { recursive: true, mode: 0o700 });
|
|
2801
|
+
if (path.resolve(sourcePath) !== path.resolve(destinationPath)) {
|
|
2802
|
+
await fs.copyFile(sourcePath, destinationPath);
|
|
2803
|
+
}
|
|
2804
|
+
await fs.chmod(destinationPath, 0o600);
|
|
2805
|
+
return destinationPath;
|
|
2608
2806
|
}
|
|
2609
2807
|
function renderEnvValue(value) {
|
|
2610
2808
|
return `"${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\$/g, '\\$')}"`;
|
|
@@ -2629,6 +2827,10 @@ async function readSecretsFile(filePath) {
|
|
|
2629
2827
|
async function writeSecretsFile(filePath, nextValues) {
|
|
2630
2828
|
const current = await readSecretsFile(filePath);
|
|
2631
2829
|
for (const [key, value] of Object.entries(nextValues)) {
|
|
2830
|
+
if (value === DELETE_SECRET) {
|
|
2831
|
+
current.delete(key);
|
|
2832
|
+
continue;
|
|
2833
|
+
}
|
|
2632
2834
|
if (value.trim())
|
|
2633
2835
|
current.set(key, value.trim());
|
|
2634
2836
|
}
|
|
@@ -2642,6 +2844,30 @@ async function writeSecretsFile(filePath, nextValues) {
|
|
|
2642
2844
|
await fs.writeFile(filePath, lines.join('\n'), { encoding: 'utf8', mode: 0o600 });
|
|
2643
2845
|
await fs.chmod(filePath, 0o600);
|
|
2644
2846
|
}
|
|
2847
|
+
function applySecretsToProcessEnv(nextValues) {
|
|
2848
|
+
for (const [key, value] of Object.entries(nextValues)) {
|
|
2849
|
+
if (value === DELETE_SECRET) {
|
|
2850
|
+
delete process.env[key];
|
|
2851
|
+
continue;
|
|
2852
|
+
}
|
|
2853
|
+
if (value.trim())
|
|
2854
|
+
process.env[key] = value.trim();
|
|
2855
|
+
}
|
|
2856
|
+
}
|
|
2857
|
+
function mergeRuntimeEnv(...sources) {
|
|
2858
|
+
const env = { ...process.env };
|
|
2859
|
+
for (const source of sources) {
|
|
2860
|
+
for (const [key, value] of Object.entries(source || {})) {
|
|
2861
|
+
if (value === DELETE_SECRET) {
|
|
2862
|
+
delete env[key];
|
|
2863
|
+
continue;
|
|
2864
|
+
}
|
|
2865
|
+
if (String(value || '').trim())
|
|
2866
|
+
env[key] = String(value).trim();
|
|
2867
|
+
}
|
|
2868
|
+
}
|
|
2869
|
+
return env;
|
|
2870
|
+
}
|
|
2645
2871
|
function renderBashSingleQuoted(value) {
|
|
2646
2872
|
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
2647
2873
|
}
|
|
@@ -3133,6 +3359,71 @@ async function verifySentryAccountsConfig(configPath, expectedAccounts) {
|
|
|
3133
3359
|
}
|
|
3134
3360
|
return { ok: true, detail: `${realAccounts.length} active Sentry-compatible account(s) configured` };
|
|
3135
3361
|
}
|
|
3362
|
+
async function upsertPaddleAccountsConfig(configPath, accounts) {
|
|
3363
|
+
if (!accounts.length || !(await fileExists(configPath)))
|
|
3364
|
+
return false;
|
|
3365
|
+
const config = await readJsonFile(configPath);
|
|
3366
|
+
const existingAccounts = Array.isArray(config?.sources?.paddle?.accounts)
|
|
3367
|
+
? config.sources.paddle.accounts
|
|
3368
|
+
: [];
|
|
3369
|
+
const merged = new Map();
|
|
3370
|
+
for (const account of existingAccounts) {
|
|
3371
|
+
const id = String(account?.id || account?.key || account?.label || '').trim();
|
|
3372
|
+
if (id)
|
|
3373
|
+
merged.set(id, account);
|
|
3374
|
+
}
|
|
3375
|
+
for (const account of accounts) {
|
|
3376
|
+
merged.set(account.id, {
|
|
3377
|
+
...(merged.get(account.id) || {}),
|
|
3378
|
+
...account,
|
|
3379
|
+
});
|
|
3380
|
+
}
|
|
3381
|
+
const tokenEnv = accounts[0]?.tokenEnv || config?.sources?.paddle?.tokenEnv || config?.secrets?.paddleTokenEnv || 'PADDLE_API_KEY';
|
|
3382
|
+
config.sources = {
|
|
3383
|
+
...(config.sources || {}),
|
|
3384
|
+
paddle: {
|
|
3385
|
+
...(config.sources?.paddle || {}),
|
|
3386
|
+
enabled: true,
|
|
3387
|
+
mode: 'command',
|
|
3388
|
+
command: normalizeWizardSourceCommand('paddle', config.sources?.paddle || {}, configPath),
|
|
3389
|
+
environment: config.sources?.paddle?.environment || 'live',
|
|
3390
|
+
tokenEnv,
|
|
3391
|
+
accounts: [...merged.values()],
|
|
3392
|
+
},
|
|
3393
|
+
};
|
|
3394
|
+
config.secrets = {
|
|
3395
|
+
...(config.secrets || {}),
|
|
3396
|
+
paddleTokenEnv: tokenEnv,
|
|
3397
|
+
paddleTokenRef: { source: 'env', provider: 'default', id: tokenEnv },
|
|
3398
|
+
};
|
|
3399
|
+
await writeJsonFile(configPath, config);
|
|
3400
|
+
return true;
|
|
3401
|
+
}
|
|
3402
|
+
async function verifyPaddleAccountsConfig(configPath, expectedAccounts) {
|
|
3403
|
+
if (!(await fileExists(configPath))) {
|
|
3404
|
+
return { ok: false, detail: `${configPath} does not exist` };
|
|
3405
|
+
}
|
|
3406
|
+
const config = await readJsonFile(configPath);
|
|
3407
|
+
const source = config?.sources?.paddle;
|
|
3408
|
+
if (!source || source.enabled !== true) {
|
|
3409
|
+
return { ok: false, detail: 'sources.paddle.enabled is not true' };
|
|
3410
|
+
}
|
|
3411
|
+
if (source.mode !== 'command') {
|
|
3412
|
+
return { ok: false, detail: 'sources.paddle.mode is not command' };
|
|
3413
|
+
}
|
|
3414
|
+
const configuredAccounts = Array.isArray(source.accounts) ? source.accounts : [];
|
|
3415
|
+
if (configuredAccounts.length === 0) {
|
|
3416
|
+
return { ok: false, detail: 'sources.paddle.accounts contains no account' };
|
|
3417
|
+
}
|
|
3418
|
+
const configuredIds = new Set(configuredAccounts.map((account) => String(account?.id || account?.key || '').trim()).filter(Boolean));
|
|
3419
|
+
const missingIds = expectedAccounts
|
|
3420
|
+
.map((account) => String(account?.id || '').trim())
|
|
3421
|
+
.filter((id) => id && !configuredIds.has(id));
|
|
3422
|
+
if (missingIds.length > 0) {
|
|
3423
|
+
return { ok: false, detail: `sources.paddle.accounts is missing configured account id(s): ${missingIds.join(', ')}` };
|
|
3424
|
+
}
|
|
3425
|
+
return { ok: true, detail: `${configuredAccounts.length} Paddle account(s) configured` };
|
|
3426
|
+
}
|
|
3136
3427
|
async function upsertCoolifyConfig(configPath, { baseUrl, tokenEnv = 'COOLIFY_API_TOKEN' }) {
|
|
3137
3428
|
if (!(await fileExists(configPath)))
|
|
3138
3429
|
return false;
|
|
@@ -3232,11 +3523,14 @@ function validateAscPrivateKeyContent(value) {
|
|
|
3232
3523
|
};
|
|
3233
3524
|
}
|
|
3234
3525
|
}
|
|
3235
|
-
async function askAscPrivateKeyContent(rl) {
|
|
3236
|
-
|
|
3237
|
-
|
|
3526
|
+
async function askAscPrivateKeyContent(rl, options = {}) {
|
|
3527
|
+
const envName = options.envName || 'ASC_PRIVATE_KEY';
|
|
3528
|
+
const persistLabel = options.persistLabel || 'ASC_PRIVATE_KEY_PATH';
|
|
3529
|
+
const keyLabel = options.keyLabel || 'this App Store Connect key';
|
|
3530
|
+
process.stdout.write(`\nPaste the full .p8 file content for ${keyLabel}.\nLeave the first line empty if the .p8 file is already saved on this host.\n`);
|
|
3531
|
+
process.stdout.write(`The wizard validates the pasted key, stores it locally with chmod 600, and only saves ${persistLabel}.\n`);
|
|
3238
3532
|
while (true) {
|
|
3239
|
-
const value = await readAscPrivateKeyPaste(rl);
|
|
3533
|
+
const value = await readAscPrivateKeyPaste(rl, envName);
|
|
3240
3534
|
if (!value.trim())
|
|
3241
3535
|
return '';
|
|
3242
3536
|
const validation = validateAscPrivateKeyContent(value);
|
|
@@ -3246,7 +3540,7 @@ async function askAscPrivateKeyContent(rl) {
|
|
|
3246
3540
|
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');
|
|
3247
3541
|
}
|
|
3248
3542
|
}
|
|
3249
|
-
async function readAscPrivateKeyPaste(rl) {
|
|
3543
|
+
async function readAscPrivateKeyPaste(rl, envName = 'ASC_PRIVATE_KEY') {
|
|
3250
3544
|
return await new Promise((resolve, reject) => {
|
|
3251
3545
|
let buffer = '';
|
|
3252
3546
|
let settled = false;
|
|
@@ -3307,7 +3601,7 @@ async function readAscPrivateKeyPaste(rl) {
|
|
|
3307
3601
|
process.stdin.setEncoding('utf8');
|
|
3308
3602
|
process.stdin.on('data', onData);
|
|
3309
3603
|
process.stdin.on('error', onError);
|
|
3310
|
-
process.stdout.write(
|
|
3604
|
+
process.stdout.write(`${envName} content: `);
|
|
3311
3605
|
process.stdin.resume();
|
|
3312
3606
|
});
|
|
3313
3607
|
}
|
|
@@ -3315,9 +3609,11 @@ async function validateAscPrivateKeyPath(filePath) {
|
|
|
3315
3609
|
const raw = await fs.readFile(filePath, 'utf8');
|
|
3316
3610
|
return validateAscPrivateKeyContent(raw);
|
|
3317
3611
|
}
|
|
3318
|
-
async function askAscPrivateKeyPath(rl) {
|
|
3612
|
+
async function askAscPrivateKeyPath(rl, options = {}) {
|
|
3613
|
+
const label = options.label || 'ASC_PRIVATE_KEY_PATH (path to AuthKey_XXXX.p8, leave empty to skip)';
|
|
3614
|
+
const defaultValue = options.defaultValue ?? process.env.ASC_PRIVATE_KEY_PATH ?? '';
|
|
3319
3615
|
while (true) {
|
|
3320
|
-
const privateKeyPath = await ask(rl,
|
|
3616
|
+
const privateKeyPath = await ask(rl, label, defaultValue);
|
|
3321
3617
|
const trimmedPath = privateKeyPath.trim();
|
|
3322
3618
|
if (!trimmedPath)
|
|
3323
3619
|
return '';
|
|
@@ -3333,6 +3629,19 @@ async function askAscPrivateKeyPath(rl) {
|
|
|
3333
3629
|
process.stdout.write('The ASC private key path was not saved. Paste a valid path, or leave empty to skip.\n');
|
|
3334
3630
|
}
|
|
3335
3631
|
}
|
|
3632
|
+
async function askAscPrivateKeyPathWithKeyId(rl, options = {}) {
|
|
3633
|
+
const keyLabel = options.keyLabel || 'App Store Connect key';
|
|
3634
|
+
while (true) {
|
|
3635
|
+
const privateKeyPath = await askAscPrivateKeyPath(rl, options);
|
|
3636
|
+
if (!privateKeyPath)
|
|
3637
|
+
return { privateKeyPath: '', keyId: '' };
|
|
3638
|
+
const keyId = inferAscKeyIdFromPrivateKeyPath(privateKeyPath);
|
|
3639
|
+
if (keyId)
|
|
3640
|
+
return { privateKeyPath, keyId };
|
|
3641
|
+
process.stdout.write(`Could not infer Key ID for ${keyLabel} from the .p8 file name.\n`);
|
|
3642
|
+
process.stdout.write('Use Apple\'s original downloaded file name: AuthKey_<KEY_ID>.p8. Do not rename the .p8 file.\n');
|
|
3643
|
+
}
|
|
3644
|
+
}
|
|
3336
3645
|
function printSection(title, lines = []) {
|
|
3337
3646
|
process.stdout.write(`\n${ANSI.bold}${title}${ANSI.reset}\n`);
|
|
3338
3647
|
process.stdout.write(`${'-'.repeat(title.length)}\n`);
|
|
@@ -3348,13 +3657,14 @@ function printBullets(lines) {
|
|
|
3348
3657
|
}
|
|
3349
3658
|
process.stdout.write('\n');
|
|
3350
3659
|
}
|
|
3660
|
+
function bold(text) {
|
|
3661
|
+
return `${ANSI.bold}${text}${ANSI.reset}`;
|
|
3662
|
+
}
|
|
3351
3663
|
async function guideGitHubConnector(rl, secrets) {
|
|
3352
3664
|
printSection('GitHub code access', [
|
|
3353
|
-
'
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
'Open the token page, select the scopes you want, then paste the token here.',
|
|
3357
|
-
'You can rerun this wizard later to change GitHub permissions.',
|
|
3665
|
+
`${bold('Create token')} here: https://github.com/settings/tokens/new`,
|
|
3666
|
+
`${bold('Scopes')}: public repos = public_repo, private repos/issues/PRs = repo.`,
|
|
3667
|
+
`${bold('Only add workflow')} if OpenClaw should edit GitHub Actions files.`,
|
|
3358
3668
|
]);
|
|
3359
3669
|
let hasGh = await commandExists('gh');
|
|
3360
3670
|
if (!hasGh) {
|
|
@@ -3363,15 +3673,6 @@ async function guideGitHubConnector(rl, secrets) {
|
|
|
3363
3673
|
if (hasGh) {
|
|
3364
3674
|
process.stdout.write('GitHub CLI is available for helper commands.\n\n');
|
|
3365
3675
|
}
|
|
3366
|
-
process.stdout.write('Token URL: https://github.com/settings/tokens/new\n\n');
|
|
3367
|
-
process.stdout.write(`${ANSI.bold}Suggested scopes${ANSI.reset}\n`);
|
|
3368
|
-
printBullets([
|
|
3369
|
-
'Public repo only: select `public_repo`.',
|
|
3370
|
-
'Private repo access: select `repo` (classic GitHub tokens make private repo access broad).',
|
|
3371
|
-
'Create issues / draft PRs in private repos: `repo` is the relevant classic-token scope.',
|
|
3372
|
-
'Edit GitHub Actions workflow files: add `workflow` only if you explicitly want this.',
|
|
3373
|
-
'Usually do not select: packages, admin:org, hooks, gist, user, delete_repo, enterprise, codespace, copilot.',
|
|
3374
|
-
]);
|
|
3375
3676
|
const token = await maybePromptSecret(rl, 'Paste GITHUB_TOKEN into this local terminal', 'GITHUB_TOKEN');
|
|
3376
3677
|
if (token)
|
|
3377
3678
|
secrets.GITHUB_TOKEN = token;
|
|
@@ -3392,12 +3693,10 @@ function shouldForceFreshAnalyticsToken(healthByConnector = {}) {
|
|
|
3392
3693
|
return ['blocked', 'partial'].includes(String(health?.status || '')) || /revoked|unauthorized|invalid token/i.test(detail);
|
|
3393
3694
|
}
|
|
3394
3695
|
async function guideAnalyticsConnector(rl, secrets, options = {}) {
|
|
3395
|
-
printSection('AnalyticsCLI'
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
process.stdout.write('3. Create Access Token\n');
|
|
3400
|
-
process.stdout.write('4. Copy the Readonly CLI Token and paste it below\n\n');
|
|
3696
|
+
printSection('AnalyticsCLI', [
|
|
3697
|
+
`${bold('Create readonly CLI token')}: https://dash.analyticscli.com/`,
|
|
3698
|
+
`${bold('Path')}: Account -> API Keys -> Create Access Token.`,
|
|
3699
|
+
]);
|
|
3401
3700
|
const forceFresh = Boolean(options.forceFresh);
|
|
3402
3701
|
if (forceFresh && process.env.ANALYTICSCLI_ACCESS_TOKEN) {
|
|
3403
3702
|
process.stdout.write('Stored token failed. Paste a new token.\n\n');
|
|
@@ -3414,51 +3713,64 @@ async function guideAnalyticsConnector(rl, secrets, options = {}) {
|
|
|
3414
3713
|
}
|
|
3415
3714
|
async function guideRevenueCatConnector(rl, secrets) {
|
|
3416
3715
|
printSection('RevenueCat monetization data', [
|
|
3417
|
-
'
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
printBullets([
|
|
3421
|
-
'Select your app.',
|
|
3422
|
-
'In the sidebar, choose "Apps & providers".',
|
|
3423
|
-
'Click "API keys" and generate a new secret API key.',
|
|
3424
|
-
'Name it "analyticscli" and choose API version 2.',
|
|
3425
|
-
'Set Charts metrics permissions to read.',
|
|
3426
|
-
'Set Customer information permissions to read.',
|
|
3427
|
-
'Set Project configuration permissions to read.',
|
|
3716
|
+
`${bold('Create secret API key')}: https://app.revenuecat.com/`,
|
|
3717
|
+
`${bold('Path')}: Apps & providers -> API keys -> New secret key.`,
|
|
3718
|
+
`${bold('Permissions')}: API v2, read for Charts metrics, Customer information, Project configuration.`,
|
|
3428
3719
|
]);
|
|
3429
3720
|
const apiKey = await maybePromptSecret(rl, 'Paste REVENUECAT_API_KEY into this local terminal', 'REVENUECAT_API_KEY');
|
|
3430
3721
|
if (apiKey)
|
|
3431
3722
|
secrets.REVENUECAT_API_KEY = apiKey;
|
|
3432
3723
|
}
|
|
3724
|
+
function paddleAccountIdFromLabel(label, index) {
|
|
3725
|
+
const normalized = String(label || '')
|
|
3726
|
+
.trim()
|
|
3727
|
+
.toLowerCase()
|
|
3728
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
3729
|
+
.replace(/^_+|_+$/g, '');
|
|
3730
|
+
return normalized || `paddle_${index + 1}`;
|
|
3731
|
+
}
|
|
3732
|
+
function paddleTokenEnvForAccount(index, label) {
|
|
3733
|
+
if (index === 0)
|
|
3734
|
+
return 'PADDLE_API_KEY';
|
|
3735
|
+
const suffix = paddleAccountIdFromLabel(label, index).toUpperCase().replace(/[^A-Z0-9]+/g, '_');
|
|
3736
|
+
const base = suffix && suffix !== `PADDLE_${index + 1}` ? `PADDLE_API_KEY_${suffix}` : `PADDLE_API_KEY_${index + 1}`;
|
|
3737
|
+
return base.replace(/_+/g, '_');
|
|
3738
|
+
}
|
|
3433
3739
|
async function guidePaddleConnector(rl, secrets) {
|
|
3434
3740
|
printSection('Paddle Billing metrics', [
|
|
3435
|
-
'
|
|
3741
|
+
`${bold('Create live API key')}: https://vendors.paddle.com/authentication-v2`,
|
|
3742
|
+
`${bold('Minimum')}: metrics.read. Better: all read-only *.read scopes.`,
|
|
3743
|
+
`${bold('Do not grant write scopes')} unless you explicitly need them elsewhere.`,
|
|
3436
3744
|
]);
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3745
|
+
const accounts = [];
|
|
3746
|
+
let index = 0;
|
|
3747
|
+
while (true) {
|
|
3748
|
+
const label = await ask(rl, index === 0 ? 'Paddle account label' : 'Next Paddle account label (empty = done)', index === 0 ? 'Paddle' : '');
|
|
3749
|
+
if (!label.trim())
|
|
3750
|
+
break;
|
|
3751
|
+
const tokenEnv = paddleTokenEnvForAccount(index, label);
|
|
3752
|
+
const apiKey = await maybePromptSecret(rl, `Paste ${tokenEnv} into this local terminal`, tokenEnv);
|
|
3753
|
+
if (apiKey)
|
|
3754
|
+
secrets[tokenEnv] = apiKey;
|
|
3755
|
+
accounts.push({
|
|
3756
|
+
id: paddleAccountIdFromLabel(label, index),
|
|
3757
|
+
label: label.trim(),
|
|
3758
|
+
tokenEnv,
|
|
3759
|
+
environment: 'live',
|
|
3760
|
+
});
|
|
3761
|
+
index += 1;
|
|
3762
|
+
const addAnother = await askYesNo(rl, 'Add another Paddle account?', false);
|
|
3763
|
+
if (!addAnother)
|
|
3764
|
+
break;
|
|
3765
|
+
}
|
|
3766
|
+
return accounts;
|
|
3448
3767
|
}
|
|
3449
3768
|
async function guideSeoConnector(rl, secrets) {
|
|
3450
3769
|
printSection('SEO / Google Search Console / DataForSEO', [
|
|
3451
|
-
'
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
'Preferred: give the token/service account access to all Search Console properties you want analyzed.',
|
|
3456
|
-
'Leave the property URL empty to let the exporter list and query all verified GSC properties in the account.',
|
|
3457
|
-
'Enter a property URL only when you intentionally want to restrict analysis to one site.',
|
|
3458
|
-
'For OAuth token mode, paste a read-only Search Console token with `webmasters.readonly` scope.',
|
|
3459
|
-
'For service-account mode, add the service account email as a restricted/full user in Search Console, then set GOOGLE_APPLICATION_CREDENTIALS or GSC_SERVICE_ACCOUNT_JSON outside this wizard.',
|
|
3460
|
-
'DataForSEO is optional and paid. The exporter refuses paid calls unless the source command includes --confirm-paid and a small --max-paid-requests cap.',
|
|
3461
|
-
'CSV-only mode is also supported with --gsc-csv or --csv in sources.seo.command.',
|
|
3770
|
+
`${bold('GSC')}: https://search.google.com/search-console`,
|
|
3771
|
+
`${bold('Service account')}: https://console.cloud.google.com/iam-admin/serviceaccounts`,
|
|
3772
|
+
`${bold('Optional paid keyword data')}: https://app.dataforseo.com/api-dashboard`,
|
|
3773
|
+
`${bold('Default')}: leave property URL empty to use all verified GSC properties.`,
|
|
3462
3774
|
]);
|
|
3463
3775
|
const siteUrl = await ask(rl, 'Optional GSC property URL (empty = all verified properties)', process.env.GSC_SITE_URL || '');
|
|
3464
3776
|
if (siteUrl.trim())
|
|
@@ -3476,10 +3788,15 @@ async function guideSeoConnector(rl, secrets) {
|
|
|
3476
3788
|
secrets.DATAFORSEO_PASSWORD = password;
|
|
3477
3789
|
}
|
|
3478
3790
|
}
|
|
3479
|
-
function buildAccountSignalExtraSourceConfig(key, existing = {}) {
|
|
3791
|
+
function buildAccountSignalExtraSourceConfig(key, existing = {}, accounts = []) {
|
|
3480
3792
|
const definition = getAccountSignalConnectorDefinition(key);
|
|
3481
3793
|
if (!definition)
|
|
3482
3794
|
return existing;
|
|
3795
|
+
const accountConfig = accounts.length > 0
|
|
3796
|
+
? {
|
|
3797
|
+
accounts: mergeConnectorAccounts(existing.accounts, accounts),
|
|
3798
|
+
}
|
|
3799
|
+
: {};
|
|
3483
3800
|
return {
|
|
3484
3801
|
...buildExtraSourceConfig(definition.service, {
|
|
3485
3802
|
key: definition.key,
|
|
@@ -3503,9 +3820,28 @@ function buildAccountSignalExtraSourceConfig(key, existing = {}) {
|
|
|
3503
3820
|
signalKind: definition.sourceKind,
|
|
3504
3821
|
experimental: Boolean(definition.experimental),
|
|
3505
3822
|
hint: existing.hint || definition.signalHint,
|
|
3823
|
+
...accountConfig,
|
|
3506
3824
|
};
|
|
3507
3825
|
}
|
|
3508
|
-
|
|
3826
|
+
function mergeConnectorAccounts(existingAccounts, nextAccounts) {
|
|
3827
|
+
const merged = new Map();
|
|
3828
|
+
for (const account of Array.isArray(existingAccounts) ? existingAccounts : []) {
|
|
3829
|
+
const id = String(account?.id || account?.key || account?.label || '').trim();
|
|
3830
|
+
if (id)
|
|
3831
|
+
merged.set(id, account);
|
|
3832
|
+
}
|
|
3833
|
+
for (const account of nextAccounts) {
|
|
3834
|
+
const id = String(account?.id || account?.key || account?.label || '').trim();
|
|
3835
|
+
if (!id)
|
|
3836
|
+
continue;
|
|
3837
|
+
merged.set(id, {
|
|
3838
|
+
...(merged.get(id) || {}),
|
|
3839
|
+
...account,
|
|
3840
|
+
});
|
|
3841
|
+
}
|
|
3842
|
+
return [...merged.values()];
|
|
3843
|
+
}
|
|
3844
|
+
async function upsertAccountSignalConnectorConfig(configPath, key, accounts = []) {
|
|
3509
3845
|
const definition = getAccountSignalConnectorDefinition(key);
|
|
3510
3846
|
if (!definition)
|
|
3511
3847
|
return false;
|
|
@@ -3514,7 +3850,7 @@ async function upsertAccountSignalConnectorConfig(configPath, key) {
|
|
|
3514
3850
|
const extra = Array.isArray(sources.extra) ? sources.extra : [];
|
|
3515
3851
|
const nextExtra = extra.filter((source) => String(source?.key || source?.service || '') !== definition.key);
|
|
3516
3852
|
const existing = extra.find((source) => String(source?.key || source?.service || '') === definition.key) || {};
|
|
3517
|
-
nextExtra.push(buildAccountSignalExtraSourceConfig(key, existing));
|
|
3853
|
+
nextExtra.push(buildAccountSignalExtraSourceConfig(key, existing, accounts));
|
|
3518
3854
|
config.sources = {
|
|
3519
3855
|
...sources,
|
|
3520
3856
|
extra: nextExtra,
|
|
@@ -3522,33 +3858,62 @@ async function upsertAccountSignalConnectorConfig(configPath, key) {
|
|
|
3522
3858
|
await writeJsonFile(configPath, config);
|
|
3523
3859
|
return true;
|
|
3524
3860
|
}
|
|
3861
|
+
function accountSignalTokenEnvForAccount(baseEnv, key, index, label) {
|
|
3862
|
+
if (index === 0)
|
|
3863
|
+
return baseEnv;
|
|
3864
|
+
const suffix = toConfigId(label || key, `${key}_${index + 1}`).toUpperCase().replace(/[^A-Z0-9]+/g, '_');
|
|
3865
|
+
return `${baseEnv}_${suffix}`.replace(/_+/g, '_');
|
|
3866
|
+
}
|
|
3525
3867
|
async function guideAccountSignalConnector(rl, secrets, key) {
|
|
3526
3868
|
const definition = getAccountSignalConnectorDefinition(key);
|
|
3527
3869
|
if (!definition)
|
|
3528
|
-
return;
|
|
3870
|
+
return [];
|
|
3529
3871
|
printSection(definition.label, [
|
|
3530
|
-
definition.
|
|
3531
|
-
'Setup is account-wide. Do not paste project IDs, app IDs, product IDs, package names, paywall IDs, service names, or tags here
|
|
3872
|
+
`${bold('Docs')}: ${definition.docsUrl}`,
|
|
3873
|
+
`${bold('Setup is account-wide')}. Do not paste project IDs, app IDs, product IDs, package names, paywall IDs, service names, or tags here.`,
|
|
3874
|
+
`${bold('Paste only credentials')} below. The agent discovers accounts/apps/projects later.`,
|
|
3532
3875
|
]);
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
|
-
const
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
const
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3876
|
+
const accounts = [];
|
|
3877
|
+
let index = 0;
|
|
3878
|
+
while (true) {
|
|
3879
|
+
const label = await ask(rl, index === 0 ? `${definition.label} account label` : `Next ${definition.label} account label (empty = done)`, index === 0 ? definition.label.replace(/\s+\(experimental\)$/i, '') : '');
|
|
3880
|
+
if (!label.trim())
|
|
3881
|
+
break;
|
|
3882
|
+
const credentialEnvs = {};
|
|
3883
|
+
for (const credential of definition.credentials) {
|
|
3884
|
+
const envName = accountSignalTokenEnvForAccount(credential.env, key, index, label);
|
|
3885
|
+
const defaultValue = index === 0 ? credential.defaultValue ?? process.env[credential.env] ?? '' : '';
|
|
3886
|
+
const prompt = envName === credential.env ? credential.prompt : `${credential.prompt.replace(credential.env, envName)}`;
|
|
3887
|
+
const value = credential.optional
|
|
3888
|
+
? await maybePromptSecret(rl, prompt, envName)
|
|
3889
|
+
: await maybePromptSecret(rl, prompt, envName);
|
|
3890
|
+
const finalValue = value || defaultValue;
|
|
3891
|
+
credentialEnvs[credential.env] = envName;
|
|
3892
|
+
if (finalValue)
|
|
3893
|
+
secrets[envName] = finalValue;
|
|
3894
|
+
else if (!credential.optional) {
|
|
3895
|
+
process.stdout.write(`${envName} was not saved. ${definition.label} setup remains pending for ${label}; rerun this wizard when ready.\n`);
|
|
3896
|
+
}
|
|
3545
3897
|
}
|
|
3898
|
+
accounts.push({
|
|
3899
|
+
id: toConfigId(label, `${key}_${index + 1}`),
|
|
3900
|
+
label: label.trim(),
|
|
3901
|
+
credentialEnvs,
|
|
3902
|
+
tokenEnv: credentialEnvs[definition.credentials[0]?.env] || definition.credentials[0]?.env || null,
|
|
3903
|
+
accountWide: true,
|
|
3904
|
+
projectScope: 'discover_from_account',
|
|
3905
|
+
});
|
|
3906
|
+
index += 1;
|
|
3907
|
+
const addAnother = await askYesNo(rl, `Add another ${definition.label} account?`, false);
|
|
3908
|
+
if (!addAnother)
|
|
3909
|
+
break;
|
|
3546
3910
|
}
|
|
3911
|
+
return accounts;
|
|
3547
3912
|
}
|
|
3548
3913
|
async function guideSentryConnector(rl, secrets) {
|
|
3549
3914
|
printSection('Sentry / GlitchTip', [
|
|
3550
|
-
'
|
|
3551
|
-
'
|
|
3915
|
+
`${bold('Base URL')}: https://sentry.io for Sentry Cloud, otherwise your GlitchTip/self-hosted URL.`,
|
|
3916
|
+
`${bold('Token + org')} are needed. Project scope remains unpinned.`,
|
|
3552
3917
|
]);
|
|
3553
3918
|
const accounts = [];
|
|
3554
3919
|
let index = 0;
|
|
@@ -3600,7 +3965,7 @@ async function guideSentryConnector(rl, secrets) {
|
|
|
3600
3965
|
let verifiedVisibleProjects = false;
|
|
3601
3966
|
if (discovery.ok && discovery.projects.length > 0) {
|
|
3602
3967
|
verifiedVisibleProjects = true;
|
|
3603
|
-
process.stdout.write(`Found ${discovery.projects.length} visible project(s). Project scope remains unpinned
|
|
3968
|
+
process.stdout.write(`Found ${discovery.projects.length} visible project(s). Project scope remains unpinned.\n`);
|
|
3604
3969
|
}
|
|
3605
3970
|
else {
|
|
3606
3971
|
const fallbackOrgs = discoveredOrganizations
|
|
@@ -3657,19 +4022,12 @@ function normalizeCoolifyBaseUrl(value) {
|
|
|
3657
4022
|
}
|
|
3658
4023
|
async function guideCoolifyConnector(rl, secrets) {
|
|
3659
4024
|
printSection('Coolify deployment monitoring', [
|
|
3660
|
-
'
|
|
3661
|
-
'
|
|
4025
|
+
`${bold('Create read-only API token')} in Coolify.`,
|
|
4026
|
+
`${bold('Do not use * or sensitive-token permissions')} for normal monitoring.`,
|
|
3662
4027
|
]);
|
|
3663
4028
|
const baseUrl = normalizeCoolifyBaseUrl(await ask(rl, 'Coolify base URL', process.env.COOLIFY_BASE_URL || 'https://coolify.wotaso.com'));
|
|
3664
4029
|
const tokenUrl = baseUrl ? `${baseUrl}/security/api-tokens` : 'https://<your-coolify-host>/security/api-tokens';
|
|
3665
|
-
process.stdout.write(
|
|
3666
|
-
printBullets([
|
|
3667
|
-
'Open the Coolify dashboard.',
|
|
3668
|
-
'In the sidebar, go to "Keys & Tokens".',
|
|
3669
|
-
'Open "API tokens".',
|
|
3670
|
-
'Create a new API key/token with read-only permissions.',
|
|
3671
|
-
'Copy the token once and paste it into this local terminal.',
|
|
3672
|
-
]);
|
|
4030
|
+
process.stdout.write(`${bold('Token page')}: ${tokenUrl}\n\n`);
|
|
3673
4031
|
const token = await maybePromptSecret(rl, 'Paste COOLIFY_API_TOKEN into this local terminal', 'COOLIFY_API_TOKEN');
|
|
3674
4032
|
if (baseUrl)
|
|
3675
4033
|
secrets.COOLIFY_BASE_URL = baseUrl;
|
|
@@ -3679,56 +4037,131 @@ async function guideCoolifyConnector(rl, secrets) {
|
|
|
3679
4037
|
}
|
|
3680
4038
|
async function guideAscConnector(rl, secrets) {
|
|
3681
4039
|
printSection('App Store Connect CLI', [
|
|
3682
|
-
'
|
|
3683
|
-
'
|
|
3684
|
-
|
|
3685
|
-
process.stdout.write('Create an App Store Connect API key here:\n https://appstoreconnect.apple.com/access/integrations/api\n\n');
|
|
3686
|
-
process.stdout.write('Roles to choose for this key:\n');
|
|
3687
|
-
printBullets([
|
|
3688
|
-
'Required for first setup: Admin, because Apple only allows Admin keys to create the initial Analytics Report Request.',
|
|
3689
|
-
'Required for steady-state report downloads after the request exists: Sales and Reports, Finance, or Admin.',
|
|
3690
|
-
'Recommended: Customer Support, for App Store ratings and review text.',
|
|
3691
|
-
'Recommended: Developer, for builds, TestFlight, and delivery status.',
|
|
3692
|
-
'Optional: App Manager, only if OpenClaw should also read or manage app metadata, pricing, or release settings.',
|
|
3693
|
-
'Least privilege option: run setup once with Admin, then rotate Growth Engineer to a Sales and Reports key for ongoing analytics downloads.',
|
|
3694
|
-
]);
|
|
3695
|
-
process.stdout.write('\nWhy Admin is requested during setup:\n');
|
|
3696
|
-
printBullets([
|
|
3697
|
-
'Growth Engineer automatically creates an ongoing App Analytics report request when none exists.',
|
|
3698
|
-
'Without that request, Apple will not generate Impressions, Product Page Views, App Units, Conversion Rate, and related report instances.',
|
|
3699
|
-
'A non-Admin key can read existing reports, but creation fails with a forbidden response.',
|
|
4040
|
+
`${bold('Create 2 API keys')} here: https://appstoreconnect.apple.com/access/integrations/api`,
|
|
4041
|
+
`1. ${bold('Reports key')} - role ${bold('Sales and Reports')} - saved for Growth Engineer.`,
|
|
4042
|
+
`2. ${bold('Setup key')} - role ${bold('Admin')} - used once, then revoke.`,
|
|
3700
4043
|
]);
|
|
3701
|
-
process.stdout.write('
|
|
4044
|
+
process.stdout.write(`${bold('Enter the Reports key now:')}\n`);
|
|
3702
4045
|
printBullets([
|
|
3703
|
-
'
|
|
3704
|
-
|
|
3705
|
-
'
|
|
3706
|
-
'
|
|
3707
|
-
'Vendor Number from App Store Connect Sales and Trends > Reports, needed for Sales and Trends/App Units reports.',
|
|
4046
|
+
`${bold('.p8 path')} to Apple\'s original ${bold('AuthKey_<KEY_ID>.p8')} file. ${bold('Do not rename it')}; KEY_ID is read from the filename.`,
|
|
4047
|
+
`The wizard saves a secure local copy with ${bold('chmod 600')}.`,
|
|
4048
|
+
`${bold('Issuer ID')} from the API keys page. Same value for both keys.`,
|
|
4049
|
+
`${bold('Vendor Number')} from Sales and Trends > Reports.`,
|
|
3708
4050
|
]);
|
|
3709
|
-
const
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
4051
|
+
const normalKeyPath = await askAscPrivateKeyPathWithKeyId(rl, {
|
|
4052
|
+
label: 'Reports .p8 path (AuthKey_<KEY_ID>.p8, empty = paste)',
|
|
4053
|
+
defaultValue: process.env.ASC_PRIVATE_KEY_PATH || '',
|
|
4054
|
+
keyLabel: 'the normal reporting key',
|
|
4055
|
+
});
|
|
4056
|
+
let keyId = normalKeyPath.keyId;
|
|
4057
|
+
if (normalKeyPath.privateKeyPath) {
|
|
4058
|
+
const securePrivateKeyPath = await copyAscPrivateKeyToSecurePath(normalKeyPath.privateKeyPath, keyId);
|
|
4059
|
+
secrets.ASC_PRIVATE_KEY_PATH = securePrivateKeyPath;
|
|
4060
|
+
secrets.ASC_PRIVATE_KEY = DELETE_SECRET;
|
|
4061
|
+
secrets.ASC_PRIVATE_KEY_B64 = DELETE_SECRET;
|
|
4062
|
+
secrets.ASC_KEY_ID = keyId;
|
|
4063
|
+
process.stdout.write(`Inferred ASC_KEY_ID=${keyId} from ${path.basename(normalKeyPath.privateKeyPath)}.\n`);
|
|
4064
|
+
process.stdout.write(`Saved secure Reports key copy to ${securePrivateKeyPath} with chmod 600.\n`);
|
|
4065
|
+
}
|
|
4066
|
+
const issuerId = await ask(rl, 'ASC_ISSUER_ID (same for both keys, empty = skip)', process.env.ASC_ISSUER_ID || '');
|
|
3713
4067
|
if (issuerId.trim())
|
|
3714
4068
|
secrets.ASC_ISSUER_ID = issuerId.trim();
|
|
3715
|
-
|
|
3716
|
-
|
|
4069
|
+
if (!normalKeyPath.privateKeyPath) {
|
|
4070
|
+
keyId = await ask(rl, 'ASC_KEY_ID (from AuthKey_<KEY_ID>.p8, empty = skip)', process.env.ASC_KEY_ID || '');
|
|
4071
|
+
if (keyId.trim())
|
|
4072
|
+
secrets.ASC_KEY_ID = keyId.trim();
|
|
4073
|
+
const privateKeyContent = await askAscPrivateKeyContent(rl, {
|
|
4074
|
+
keyLabel: 'the normal reporting key',
|
|
4075
|
+
});
|
|
4076
|
+
if (!privateKeyContent)
|
|
4077
|
+
return await guideAscBootstrapAdminKey(rl, issuerId.trim());
|
|
3717
4078
|
const privateKeyPath = resolveAscPrivateKeyPath(keyId);
|
|
3718
4079
|
await fs.mkdir(path.dirname(privateKeyPath), { recursive: true, mode: 0o700 });
|
|
3719
4080
|
await fs.writeFile(privateKeyPath, privateKeyContent, { encoding: 'utf8', mode: 0o600 });
|
|
3720
4081
|
await fs.chmod(privateKeyPath, 0o600);
|
|
3721
4082
|
secrets.ASC_PRIVATE_KEY_PATH = privateKeyPath;
|
|
4083
|
+
secrets.ASC_PRIVATE_KEY = DELETE_SECRET;
|
|
4084
|
+
secrets.ASC_PRIVATE_KEY_B64 = DELETE_SECRET;
|
|
3722
4085
|
process.stdout.write(`Saved ASC private key to ${privateKeyPath} with chmod 600.\n`);
|
|
3723
4086
|
}
|
|
3724
|
-
|
|
3725
|
-
const privateKeyPath = await askAscPrivateKeyPath(rl);
|
|
3726
|
-
if (privateKeyPath.trim())
|
|
3727
|
-
secrets.ASC_PRIVATE_KEY_PATH = privateKeyPath.trim();
|
|
3728
|
-
}
|
|
3729
|
-
const vendorNumber = await ask(rl, 'ASC_VENDOR_NUMBER for Sales and Trends/App Units (leave empty to skip)', process.env.ASC_VENDOR_NUMBER || process.env.ASC_ANALYTICS_VENDOR_NUMBER || '');
|
|
4087
|
+
const vendorNumber = await ask(rl, 'ASC_VENDOR_NUMBER (Sales and Trends > Reports)', process.env.ASC_VENDOR_NUMBER || '');
|
|
3730
4088
|
if (vendorNumber.trim())
|
|
3731
4089
|
secrets.ASC_VENDOR_NUMBER = vendorNumber.trim();
|
|
4090
|
+
return await guideAscBootstrapAdminKey(rl, issuerId.trim());
|
|
4091
|
+
}
|
|
4092
|
+
async function guideAscBootstrapAdminKey(rl, issuerIdDefault = '') {
|
|
4093
|
+
const bootstrapEnv = {};
|
|
4094
|
+
process.stdout.write(`\n${bold('Enter the Setup Admin key:')}\n`);
|
|
4095
|
+
printBullets([
|
|
4096
|
+
`${bold('Role must be Admin')} so Apple can create the first App Analytics report request.`,
|
|
4097
|
+
`${bold('Use original AuthKey_<KEY_ID>.p8 filename')} so KEY_ID is read automatically.`,
|
|
4098
|
+
`${bold('Not saved')} to secrets.env. The temporary secure copy is deleted after setup.`,
|
|
4099
|
+
]);
|
|
4100
|
+
const bootstrapKeyPath = await askAscPrivateKeyPathWithKeyId(rl, {
|
|
4101
|
+
label: 'Setup Admin .p8 path (AuthKey_<KEY_ID>.p8, empty = paste)',
|
|
4102
|
+
defaultValue: '',
|
|
4103
|
+
keyLabel: 'the temporary Admin key',
|
|
4104
|
+
});
|
|
4105
|
+
let bootstrapKeyId = bootstrapKeyPath.keyId;
|
|
4106
|
+
let bootstrapIssuerId = String(issuerIdDefault || '').trim();
|
|
4107
|
+
if (!bootstrapIssuerId) {
|
|
4108
|
+
bootstrapIssuerId = await ask(rl, 'ASC_ISSUER_ID (same API keys page)', process.env.ASC_ISSUER_ID || '');
|
|
4109
|
+
}
|
|
4110
|
+
if (bootstrapKeyPath.privateKeyPath) {
|
|
4111
|
+
const secureBootstrapPath = await copyAscPrivateKeyToSecurePath(bootstrapKeyPath.privateKeyPath, bootstrapKeyId, '_bootstrap_admin');
|
|
4112
|
+
bootstrapEnv.ASC_BOOTSTRAP_PRIVATE_KEY_PATH = secureBootstrapPath;
|
|
4113
|
+
process.stdout.write(`Inferred ASC_BOOTSTRAP_KEY_ID=${bootstrapKeyId} from ${path.basename(bootstrapKeyPath.privateKeyPath)}.\n`);
|
|
4114
|
+
process.stdout.write(`Saved secure temporary Admin key copy to ${secureBootstrapPath} with chmod 600.\n`);
|
|
4115
|
+
bootstrapEnv.ASC_BOOTSTRAP_PRIVATE_KEY_DELETE_AFTER_USE = '1';
|
|
4116
|
+
}
|
|
4117
|
+
else {
|
|
4118
|
+
bootstrapKeyId = await ask(rl, 'ASC_BOOTSTRAP_KEY_ID (from AuthKey_<KEY_ID>.p8)', '');
|
|
4119
|
+
}
|
|
4120
|
+
if (!bootstrapKeyId.trim() || !bootstrapIssuerId.trim())
|
|
4121
|
+
return { bootstrapEnv };
|
|
4122
|
+
bootstrapEnv.ASC_BOOTSTRAP_KEY_ID = bootstrapKeyId.trim();
|
|
4123
|
+
bootstrapEnv.ASC_BOOTSTRAP_ISSUER_ID = bootstrapIssuerId.trim();
|
|
4124
|
+
if (!bootstrapKeyPath.privateKeyPath) {
|
|
4125
|
+
const bootstrapPrivateKeyContent = await askAscPrivateKeyContent(rl, {
|
|
4126
|
+
envName: 'ASC_BOOTSTRAP_PRIVATE_KEY',
|
|
4127
|
+
persistLabel: 'ASC_BOOTSTRAP_PRIVATE_KEY_PATH temporarily',
|
|
4128
|
+
keyLabel: 'the temporary Admin key',
|
|
4129
|
+
});
|
|
4130
|
+
if (!bootstrapPrivateKeyContent)
|
|
4131
|
+
return { bootstrapEnv };
|
|
4132
|
+
const bootstrapPrivateKeyPath = resolveAscPrivateKeyPath(bootstrapKeyId, '_bootstrap_admin');
|
|
4133
|
+
await fs.mkdir(path.dirname(bootstrapPrivateKeyPath), { recursive: true, mode: 0o700 });
|
|
4134
|
+
await fs.writeFile(bootstrapPrivateKeyPath, bootstrapPrivateKeyContent, { encoding: 'utf8', mode: 0o600 });
|
|
4135
|
+
await fs.chmod(bootstrapPrivateKeyPath, 0o600);
|
|
4136
|
+
bootstrapEnv.ASC_BOOTSTRAP_PRIVATE_KEY_PATH = bootstrapPrivateKeyPath;
|
|
4137
|
+
bootstrapEnv.ASC_BOOTSTRAP_PRIVATE_KEY_DELETE_AFTER_USE = '1';
|
|
4138
|
+
process.stdout.write(`Saved temporary Admin ASC private key to ${bootstrapPrivateKeyPath} with chmod 600. It will be deleted after the setup check.\n`);
|
|
4139
|
+
}
|
|
4140
|
+
return { bootstrapEnv };
|
|
4141
|
+
}
|
|
4142
|
+
async function cleanupTemporaryAscBootstrapPrivateKey(bootstrapEnv = {}) {
|
|
4143
|
+
const privateKeyPath = String(bootstrapEnv.ASC_BOOTSTRAP_PRIVATE_KEY_PATH || '').trim();
|
|
4144
|
+
const shouldDelete = String(bootstrapEnv.ASC_BOOTSTRAP_PRIVATE_KEY_DELETE_AFTER_USE || '').trim().toLowerCase();
|
|
4145
|
+
if (!privateKeyPath || !['1', 'true', 'yes'].includes(shouldDelete))
|
|
4146
|
+
return;
|
|
4147
|
+
if (privateKeyPath === String(bootstrapEnv.ASC_PRIVATE_KEY_PATH || process.env.ASC_PRIVATE_KEY_PATH || '').trim()) {
|
|
4148
|
+
process.stdout.write('Temporary Admin .p8 path matches the steady-state ASC_PRIVATE_KEY_PATH; leaving it in place.\n');
|
|
4149
|
+
process.stdout.write('You can also revoke the temporary Admin API key in App Store Connect.\n');
|
|
4150
|
+
return;
|
|
4151
|
+
}
|
|
4152
|
+
try {
|
|
4153
|
+
await fs.unlink(privateKeyPath);
|
|
4154
|
+
process.stdout.write(`Deleted temporary Admin .p8 from ${privateKeyPath}.\n`);
|
|
4155
|
+
}
|
|
4156
|
+
catch (error) {
|
|
4157
|
+
if (error?.code === 'ENOENT') {
|
|
4158
|
+
process.stdout.write(`Temporary Admin .p8 was already absent at ${privateKeyPath}.\n`);
|
|
4159
|
+
process.stdout.write('You can also revoke the temporary Admin API key in App Store Connect.\n');
|
|
4160
|
+
return;
|
|
4161
|
+
}
|
|
4162
|
+
process.stdout.write(`Could not delete temporary Admin .p8 at ${privateKeyPath}: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
4163
|
+
}
|
|
4164
|
+
process.stdout.write('You can also revoke the temporary Admin API key in App Store Connect.\n');
|
|
3732
4165
|
}
|
|
3733
4166
|
async function shouldRunSelfUpdate(workspaceRoot, force) {
|
|
3734
4167
|
if (force)
|
|
@@ -3852,6 +4285,7 @@ async function runConnectorSetupSteps({ rl, args, selected, healthByConnector, a
|
|
|
3852
4285
|
process.stdout.write('\n');
|
|
3853
4286
|
const secrets = {};
|
|
3854
4287
|
let sentryAccounts = [];
|
|
4288
|
+
let paddleAccounts = [];
|
|
3855
4289
|
let coolifyConfig = null;
|
|
3856
4290
|
if (selected.includes('analytics')) {
|
|
3857
4291
|
let forceFreshAnalyticsToken = shouldForceFreshAnalyticsToken(healthByConnector);
|
|
@@ -3900,12 +4334,13 @@ async function runConnectorSetupSteps({ rl, args, selected, healthByConnector, a
|
|
|
3900
4334
|
if (selected.includes('paddle')) {
|
|
3901
4335
|
while (true) {
|
|
3902
4336
|
clearTerminal();
|
|
3903
|
-
await guidePaddleConnector(rl, secrets);
|
|
4337
|
+
paddleAccounts = await guidePaddleConnector(rl, secrets);
|
|
3904
4338
|
const check = await runImmediateConnectorHealthCheck({
|
|
3905
4339
|
rl,
|
|
3906
4340
|
configPath: args.config,
|
|
3907
4341
|
connector: 'paddle',
|
|
3908
4342
|
secrets,
|
|
4343
|
+
paddleAccounts,
|
|
3909
4344
|
});
|
|
3910
4345
|
if (!check.retry)
|
|
3911
4346
|
break;
|
|
@@ -3960,13 +4395,16 @@ async function runConnectorSetupSteps({ rl, args, selected, healthByConnector, a
|
|
|
3960
4395
|
if (selected.includes('asc')) {
|
|
3961
4396
|
while (true) {
|
|
3962
4397
|
clearTerminal();
|
|
3963
|
-
await guideAscConnector(rl, secrets);
|
|
3964
|
-
|
|
4398
|
+
const ascSetup = await guideAscConnector(rl, secrets);
|
|
4399
|
+
let bootstrapEnv = ascSetup?.bootstrapEnv || {};
|
|
4400
|
+
let check = await runImmediateConnectorHealthCheck({
|
|
3965
4401
|
rl,
|
|
3966
4402
|
configPath: args.config,
|
|
3967
4403
|
connector: 'asc',
|
|
3968
4404
|
secrets,
|
|
4405
|
+
runtimeEnv: bootstrapEnv,
|
|
3969
4406
|
});
|
|
4407
|
+
await cleanupTemporaryAscBootstrapPrivateKey(bootstrapEnv);
|
|
3970
4408
|
if (!check.retry)
|
|
3971
4409
|
break;
|
|
3972
4410
|
}
|
|
@@ -3974,8 +4412,8 @@ async function runConnectorSetupSteps({ rl, args, selected, healthByConnector, a
|
|
|
3974
4412
|
for (const connector of selected.filter(isAccountSignalConnector)) {
|
|
3975
4413
|
while (true) {
|
|
3976
4414
|
clearTerminal();
|
|
3977
|
-
await guideAccountSignalConnector(rl, secrets, connector);
|
|
3978
|
-
await upsertAccountSignalConnectorConfig(args.config, connector);
|
|
4415
|
+
const accountSignalAccounts = await guideAccountSignalConnector(rl, secrets, connector);
|
|
4416
|
+
await upsertAccountSignalConnectorConfig(args.config, connector, accountSignalAccounts);
|
|
3979
4417
|
const check = await runImmediateConnectorHealthCheck({
|
|
3980
4418
|
rl,
|
|
3981
4419
|
configPath: args.config,
|
|
@@ -4002,13 +4440,16 @@ async function runConnectorSetupSteps({ rl, args, selected, healthByConnector, a
|
|
|
4002
4440
|
process.stdout.write(`Configured ${sentryAccounts.length} Sentry-compatible account(s) in ${args.config}.\n`);
|
|
4003
4441
|
}
|
|
4004
4442
|
}
|
|
4443
|
+
if (paddleAccounts.length > 0 && await upsertPaddleAccountsConfig(args.config, paddleAccounts)) {
|
|
4444
|
+
const readiness = await verifyPaddleAccountsConfig(args.config, paddleAccounts);
|
|
4445
|
+
if (readiness.ok) {
|
|
4446
|
+
process.stdout.write(`Configured ${paddleAccounts.length} Paddle account(s) in ${args.config}.\n`);
|
|
4447
|
+
}
|
|
4448
|
+
}
|
|
4005
4449
|
if (coolifyConfig?.baseUrl && await upsertCoolifyConfig(args.config, coolifyConfig)) {
|
|
4006
4450
|
process.stdout.write(`Configured Coolify monitoring for ${coolifyConfig.baseUrl} in ${args.config}.\n`);
|
|
4007
4451
|
}
|
|
4008
|
-
const env = {
|
|
4009
|
-
...process.env,
|
|
4010
|
-
...secrets,
|
|
4011
|
-
};
|
|
4452
|
+
const env = mergeRuntimeEnv(secrets, selected.includes('asc') ? { ASC_BYPASS_KEYCHAIN: '1' } : {});
|
|
4012
4453
|
const command = `${nodeRuntimeScriptCommand('openclaw-growth-start.mjs')} --config ${quote(args.config)} --setup-only --connectors ${quote(selected.join(','))} --only-connectors ${quote(selected.join(','))}`;
|
|
4013
4454
|
let setupResult = await runSetupCommandWithProgress(command, env, selected, 'Testing connector setup...');
|
|
4014
4455
|
let setupPayload = parseJsonFromStdout(setupResult.stdout);
|
|
@@ -4026,6 +4467,19 @@ async function runConnectorSetupSteps({ rl, args, selected, healthByConnector, a
|
|
|
4026
4467
|
});
|
|
4027
4468
|
}
|
|
4028
4469
|
}
|
|
4470
|
+
if (paddleAccounts.length > 0 && await upsertPaddleAccountsConfig(args.config, paddleAccounts)) {
|
|
4471
|
+
const readiness = await verifyPaddleAccountsConfig(args.config, paddleAccounts);
|
|
4472
|
+
if (readiness.ok) {
|
|
4473
|
+
process.stdout.write(`Paddle account config is up to date in ${args.config}.\n`);
|
|
4474
|
+
}
|
|
4475
|
+
else {
|
|
4476
|
+
postSetupBlockers.push({
|
|
4477
|
+
check: 'connection:paddle',
|
|
4478
|
+
detail: readiness.detail,
|
|
4479
|
+
remediation: 'Rerun Paddle setup so the active config persists sources.paddle.enabled=true and sources.paddle.accounts[].',
|
|
4480
|
+
});
|
|
4481
|
+
}
|
|
4482
|
+
}
|
|
4029
4483
|
if (coolifyConfig?.baseUrl && await upsertCoolifyConfig(args.config, coolifyConfig)) {
|
|
4030
4484
|
process.stdout.write(`Coolify config is up to date in ${args.config}.\n`);
|
|
4031
4485
|
}
|