@meffecta/agent 1.0.2 → 1.0.7
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/README.md +50 -2
- package/bin/meffecta-agent.js +66 -14
- package/deployment.env.example +24 -11
- package/engine.json +2 -2
- package/lib/analytics.js +262 -0
- package/lib/commands.js +244 -22
- package/lib/create-job.js +344 -0
- package/lib/doctor.js +36 -8
- package/lib/gcloud.js +42 -13
- package/lib/integrations.js +53 -2
- package/lib/resources.js +300 -0
- package/lib/verify-credentials.js +129 -0
- package/lib/version.js +90 -0
- package/package.json +3 -2
- package/scripts/create-project.sh +3 -1
- package/scripts/deploy.sh +20 -9
- package/scripts/lib/cli-names.sh +55 -0
- package/scripts/lib/deployment.sh +18 -3
- package/scripts/lib/read-json.mjs +1 -1
- package/scripts/lib/tasks-queue.sh +24 -0
- package/scripts/link-billing.sh +1 -1
- package/scripts/mint-gmail-token.mjs +7 -2
- package/scripts/mint-graph-token.mjs +7 -2
- package/scripts/set-secret.sh +2 -2
- package/scripts/setup-infrastructure.sh +187 -100
- package/scripts/{setup-scheduler.sh → sync-triggers.sh} +31 -29
- package/scripts/update-tooling.sh +3 -1
- package/scripts/verify-credentials.mjs +220 -49
|
@@ -32,12 +32,13 @@ set -euo pipefail
|
|
|
32
32
|
# is restarted for them anyway. scripts/deploy.sh does it automatically.
|
|
33
33
|
#
|
|
34
34
|
# Usage:
|
|
35
|
-
# scripts/
|
|
36
|
-
# scripts/
|
|
35
|
+
# scripts/sync-triggers.sh # from the content repo (./deployment.env)
|
|
36
|
+
# scripts/sync-triggers.sh --dry-run # print what it would create, change nothing
|
|
37
37
|
#
|
|
38
38
|
# Which deployment: ./deployment.env, or --config <file>. See deployment.env.example.
|
|
39
39
|
|
|
40
40
|
. "$(dirname "${BASH_SOURCE[0]}")/lib/deployment.sh"
|
|
41
|
+
. "$(dirname "${BASH_SOURCE[0]}")/lib/tasks-queue.sh"
|
|
41
42
|
|
|
42
43
|
DRY_RUN=false
|
|
43
44
|
QUEUE_NAME=""
|
|
@@ -70,8 +71,8 @@ GC="gcloud --project=${PROJECT}"
|
|
|
70
71
|
# arguments of the calls that create them — and gcloud echoes the arguments back when it
|
|
71
72
|
# rejects one. Everything this script prints goes through here first.
|
|
72
73
|
redact() {
|
|
73
|
-
if [ -n "${
|
|
74
|
-
printf '%s\n' "${1//${
|
|
74
|
+
if [ -n "${API_SECRET:-}" ]; then
|
|
75
|
+
printf '%s\n' "${1//${API_SECRET}/<secret>}"
|
|
75
76
|
else
|
|
76
77
|
printf '%s\n' "$1"
|
|
77
78
|
fi
|
|
@@ -119,18 +120,24 @@ fi
|
|
|
119
120
|
|
|
120
121
|
# The triggers authenticate with the same bearer token as everything else on this API, so
|
|
121
122
|
# the secret has to be resolved — it is normally a Secret Manager reference on the service.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
123
|
+
# AGENT_WEBHOOK_SECRET is the old name, still read by the engine and still looked for here,
|
|
124
|
+
# so a deployment part-way through the rename gets working triggers either way.
|
|
125
|
+
API_SECRET=""
|
|
126
|
+
for SECRET_VAR in AGENT_API_SECRET AGENT_WEBHOOK_SECRET; do
|
|
127
|
+
IFS=$'\t' read -r SECRET_KIND SECRET_A SECRET_B <<<"$(printf '%s' "${SERVICE_JSON}" | svc_field "${SECRET_VAR}")"
|
|
128
|
+
if [ "${SECRET_KIND}" = "value" ]; then
|
|
129
|
+
API_SECRET="${SECRET_A}"
|
|
130
|
+
elif [ "${SECRET_KIND}" = "secret" ] && [ -n "${SECRET_A}" ]; then
|
|
131
|
+
API_SECRET=$(${GC} secrets versions access "${SECRET_B}" --secret="${SECRET_A}")
|
|
132
|
+
fi
|
|
133
|
+
[ -n "${API_SECRET}" ] && break
|
|
134
|
+
done
|
|
135
|
+
if [ -z "${API_SECRET}" ]; then
|
|
136
|
+
echo "Could not resolve AGENT_API_SECRET (or the old AGENT_WEBHOOK_SECRET) from ${SERVICE}." >&2
|
|
137
|
+
echo " $(cmd set-secret) AGENT_API_SECRET --random" >&2
|
|
138
|
+
echo "Refusing to create triggers that would 401 on every firing." >&2
|
|
131
139
|
exit 1
|
|
132
140
|
fi
|
|
133
|
-
[ -n "${WEBHOOK_SECRET}" ] || { echo "AGENT_WEBHOOK_SECRET resolved empty — refusing to create triggers that would 401." >&2; exit 1; }
|
|
134
141
|
|
|
135
142
|
echo " url: ${AGENT_URL}"
|
|
136
143
|
echo " runtime: ${RUNTIME_SA}"
|
|
@@ -142,25 +149,20 @@ if [ "${MIN_SCALE}" != "0" ] || [ "${CPU_THROTTLING}" = "false" ]; then
|
|
|
142
149
|
⚠️ ${SERVICE} still runs always-on (min-instances=${MIN_SCALE}, cpu-throttling=${CPU_THROTTLING}).
|
|
143
150
|
External triggers work either way, but the saving does not arrive until the service
|
|
144
151
|
scales to zero — and until then in-process timers and Cloud Scheduler would BOTH fire,
|
|
145
|
-
so every cron runs twice. The shape lives in deployment.env and
|
|
152
|
+
so every cron runs twice. The shape lives in deployment.env, and is asserted on every
|
|
153
|
+
rollout:
|
|
146
154
|
|
|
147
155
|
SCALING=scale-to-zero # in deployment.env, then
|
|
148
|
-
|
|
156
|
+
$(cmd deploy) # rolls it out (--dry-run to see the shape first)
|
|
149
157
|
|
|
150
158
|
EOF
|
|
151
159
|
fi
|
|
152
160
|
|
|
161
|
+
# Normally already there — setup-infrastructure.sh creates it with the deployment's other
|
|
162
|
+
# static resources. Ensured here too for a deployment provisioned before that was true, and
|
|
163
|
+
# because this script is also run on its own. See lib/tasks-queue.sh.
|
|
153
164
|
echo "📥 Cloud Tasks queue ${QUEUE}..."
|
|
154
|
-
|
|
155
|
-
run ${GC} tasks queues create "${QUEUE}" --location="${REGION}" --quiet
|
|
156
|
-
fi
|
|
157
|
-
# max-concurrent-dispatches leaves room for several held attempts at once; the engine's own
|
|
158
|
-
# queue is serial, so this bounds requests waiting, not jobs running.
|
|
159
|
-
run ${GC} tasks queues update "${QUEUE}" --location="${REGION}" \
|
|
160
|
-
--max-attempts=4 --min-backoff=5s --max-backoff=30s --max-doublings=2 \
|
|
161
|
-
--max-concurrent-dispatches=8 --max-dispatches-per-second=2 --quiet
|
|
162
|
-
run ${GC} tasks queues add-iam-policy-binding "${QUEUE}" --location="${REGION}" \
|
|
163
|
-
--member="serviceAccount:${RUNTIME_SA}" --role="roles/cloudtasks.enqueuer" --quiet
|
|
165
|
+
ensure_tasks_queue "${QUEUE}"
|
|
164
166
|
|
|
165
167
|
CURRENT_QUEUE=$(printf '%s' "${SERVICE_JSON}" | svc_field AGENT_TASKS_QUEUE | cut -f2)
|
|
166
168
|
CURRENT_URL=$(printf '%s' "${SERVICE_JSON}" | svc_field AGENT_PUBLIC_URL | cut -f2)
|
|
@@ -171,7 +173,7 @@ if [ "${CURRENT_QUEUE}" != "${QUEUE_PATH}" ] || [ "${CURRENT_URL}" != "${AGENT_U
|
|
|
171
173
|
fi
|
|
172
174
|
|
|
173
175
|
echo "📋 Reading the job list from ${AGENT_URL}/jobs..."
|
|
174
|
-
JOBS_JSON=$(curl -fsS --max-time 300 -H "Authorization: Bearer ${
|
|
176
|
+
JOBS_JSON=$(curl -fsS --max-time 300 -H "Authorization: Bearer ${API_SECRET}" "${AGENT_URL}/jobs") || {
|
|
175
177
|
cat >&2 <<EOF
|
|
176
178
|
Could not read ${AGENT_URL}/jobs.
|
|
177
179
|
|
|
@@ -179,7 +181,7 @@ The engine is the only thing that knows which jobs exist — it reads them from
|
|
|
179
181
|
clone of the content repo. So the service has to be deployed and healthy first:
|
|
180
182
|
|
|
181
183
|
curl -fsS ${AGENT_URL}/health
|
|
182
|
-
|
|
184
|
+
$(cmd deploy)
|
|
183
185
|
|
|
184
186
|
A first request after a scale-to-zero idle also has to wait out a cold start, which
|
|
185
187
|
includes cloning the content repo.
|
|
@@ -193,7 +195,7 @@ EOF
|
|
|
193
195
|
CRON_JOBS=$(printf '%s' "${JOBS_JSON}" | node "${READ_JSON}" crons)
|
|
194
196
|
WATCHES_INBOX=$(printf '%s' "${JOBS_JSON}" | node "${READ_JSON}" watches-inbox)
|
|
195
197
|
|
|
196
|
-
HEADERS="Content-Type=application/json,Authorization=Bearer ${
|
|
198
|
+
HEADERS="Content-Type=application/json,Authorization=Bearer ${API_SECRET}"
|
|
197
199
|
|
|
198
200
|
# `create http` takes --headers; `update http` insists on --update-headers for the same
|
|
199
201
|
# thing. Everything else about the two calls is identical.
|
|
@@ -30,6 +30,8 @@ set -euo pipefail
|
|
|
30
30
|
# Artifact Registry). A deployment using an AR remote repository instead still needs it
|
|
31
31
|
# here — once per engine version, rather than once per deploy.
|
|
32
32
|
|
|
33
|
+
. "$(dirname "${BASH_SOURCE[0]}")/lib/cli-names.sh"
|
|
34
|
+
|
|
33
35
|
GHCR_IMAGE="${GHCR_IMAGE:-ghcr.io/meffecta/agent}"
|
|
34
36
|
TAG="latest"
|
|
35
37
|
IMAGE=""
|
|
@@ -114,5 +116,5 @@ fi
|
|
|
114
116
|
# This script is one of the files being overwritten, and bash reads a script as it runs it.
|
|
115
117
|
# exec hands the process over so nothing further is read from the file underneath us.
|
|
116
118
|
echo "✅ Updated. Review with \`git diff\`, then deploy the matching engine:"
|
|
117
|
-
echo "
|
|
119
|
+
echo " $(cmd deploy)${TAG:+ --tag ${TAG}}"
|
|
118
120
|
exec cp -R "${TMP}/scripts/." "${DEST}/"
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
// Never prints a credential value. Exits 1 if anything configured actually fails.
|
|
9
9
|
|
|
10
10
|
import { execFile } from "node:child_process";
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
11
13
|
|
|
12
14
|
const env = process.env;
|
|
13
15
|
const results = [];
|
|
@@ -45,12 +47,21 @@ async function metadataToken(scopes) {
|
|
|
45
47
|
|
|
46
48
|
async function checkGithub() {
|
|
47
49
|
if (!env.GITHUB_TOKEN) {
|
|
48
|
-
|
|
50
|
+
// Not a shrug: the service clones the content repo at boot, so unset is an outage.
|
|
51
|
+
return fail("GITHUB_TOKEN", "unset — the service cannot clone its content repo without it");
|
|
49
52
|
}
|
|
50
|
-
// The content repo (from GIT_REPO_URL, when it
|
|
51
|
-
//
|
|
53
|
+
// The content repo (from GIT_REPO_URL, when it is a GitHub URL), plus any working repo
|
|
54
|
+
// this deployment names in a *_GIT_URL variable — its jobs clone those, so the token has
|
|
55
|
+
// to reach them. Nothing here may name a company: this script ships to every deployment.
|
|
52
56
|
const contentRepo = env.GIT_REPO_URL?.match(/github\.com\/([^/]+\/[^/.]+)/)?.[1];
|
|
53
|
-
const
|
|
57
|
+
const named = Object.entries(env)
|
|
58
|
+
.filter(([k]) => k.endsWith("_GIT_URL"))
|
|
59
|
+
.map(([, v]) => v?.match(/github\.com\/([^/]+\/[^/.]+)/)?.[1])
|
|
60
|
+
.filter(Boolean);
|
|
61
|
+
const repos = [...new Set([...(contentRepo ? [contentRepo] : []), ...named])];
|
|
62
|
+
if (repos.length === 0) {
|
|
63
|
+
return skip("GITHUB_TOKEN", "set, but no GitHub repo to check it against");
|
|
64
|
+
}
|
|
54
65
|
const codes = await Promise.all(
|
|
55
66
|
repos.map(
|
|
56
67
|
async (r) =>
|
|
@@ -87,35 +98,38 @@ async function checkResend() {
|
|
|
87
98
|
: fail("RESEND_API_KEY", `domains → ${status}`);
|
|
88
99
|
}
|
|
89
100
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
101
|
+
/**
|
|
102
|
+
* Every read-only Postgres URL this deployment declares, by the documented convention
|
|
103
|
+
* <WORLD>_POSTGRES_URL_READONLY — the variable names belong to the deployment, so they are
|
|
104
|
+
* discovered rather than listed here.
|
|
105
|
+
*/
|
|
106
|
+
// There is deliberately no check for a deployment's own product API. Probing one means
|
|
107
|
+
// knowing its path and its response shape — /v1/internal/leads was SweepOS's — and this
|
|
108
|
+
// script ships to every deployment. A credential the engine's own skills use is fair game
|
|
109
|
+
// here; a company's API is that company's tooling.
|
|
100
110
|
function checkPostgres() {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
111
|
+
const urls = Object.keys(env).filter((k) => k.endsWith("_POSTGRES_URL_READONLY") && env[k]);
|
|
112
|
+
if (urls.length === 0) {
|
|
113
|
+
skip("POSTGRES (read-only)", "no <WORLD>_POSTGRES_URL_READONLY set");
|
|
114
|
+
return Promise.resolve();
|
|
115
|
+
}
|
|
116
|
+
return Promise.all(
|
|
117
|
+
urls.map(
|
|
118
|
+
(name) =>
|
|
119
|
+
new Promise((resolve) => {
|
|
120
|
+
execFile("psql", [env[name], "-Atc", "SELECT 1"], { timeout: 10_000 }, (err, stdout) => {
|
|
121
|
+
if (err) {
|
|
122
|
+
err.code === "ENOENT"
|
|
123
|
+
? skip(name, "psql not installed here")
|
|
124
|
+
: fail(name, err.message.split("\n")[0].slice(0, 120));
|
|
125
|
+
} else {
|
|
126
|
+
stdout.trim() === "1" ? ok(name) : fail(name, "unexpected result");
|
|
127
|
+
}
|
|
128
|
+
resolve();
|
|
129
|
+
});
|
|
130
|
+
}),
|
|
131
|
+
),
|
|
132
|
+
);
|
|
119
133
|
}
|
|
120
134
|
|
|
121
135
|
async function checkPosthog() {
|
|
@@ -138,11 +152,13 @@ async function checkAhrefs() {
|
|
|
138
152
|
if (!env.AHREFS_API_KEY) {
|
|
139
153
|
return skip("AHREFS_API_KEY", "unset");
|
|
140
154
|
}
|
|
141
|
-
|
|
155
|
+
// example.com rather than a domain belonging to any deployment: this only proves the key
|
|
156
|
+
// is accepted, and the answer for a real domain is a job's business, not a doctor's.
|
|
157
|
+
const { status, json } = await http("https://api.ahrefs.com/v3/public/domain-rating-free?target=example.com", {
|
|
142
158
|
headers: { Authorization: `Bearer ${env.AHREFS_API_KEY}` },
|
|
143
159
|
});
|
|
144
160
|
status === 200
|
|
145
|
-
? ok("AHREFS_API_KEY", `
|
|
161
|
+
? ok("AHREFS_API_KEY", `example.com DR ${json?.domain_rating?.domain_rating ?? "?"}`)
|
|
146
162
|
: fail("AHREFS_API_KEY", `domain-rating-free → ${status}`);
|
|
147
163
|
}
|
|
148
164
|
|
|
@@ -164,6 +180,48 @@ async function checkSerper() {
|
|
|
164
180
|
return fail("SERPER_API_KEY", json?.message ? `${json.message} (${status})` : `search → ${status}`);
|
|
165
181
|
}
|
|
166
182
|
|
|
183
|
+
async function checkHubspot() {
|
|
184
|
+
const token = env.HUBSPOT_TOKEN;
|
|
185
|
+
if (!token) {
|
|
186
|
+
return skip("HUBSPOT_TOKEN", "unset");
|
|
187
|
+
}
|
|
188
|
+
// limit=1 with no properties: the cheapest call that still proves the token and a scope.
|
|
189
|
+
const { status, json } = await http("https://api.hubapi.com/crm/v3/objects/contacts?limit=1", {
|
|
190
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
191
|
+
});
|
|
192
|
+
if (status === 200) {
|
|
193
|
+
return ok("HUBSPOT_TOKEN", "CRM readable");
|
|
194
|
+
}
|
|
195
|
+
// 403 is a granted-scopes problem, not a bad token — different fix, so say which.
|
|
196
|
+
return fail(
|
|
197
|
+
"HUBSPOT_TOKEN",
|
|
198
|
+
status === 403 ? "token valid but missing CRM read scope (403)" : `contacts → ${status}`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function checkKleer() {
|
|
203
|
+
const token = env.KLEER_API_TOKEN;
|
|
204
|
+
if (!token) {
|
|
205
|
+
return skip("KLEER_API_TOKEN", "unset");
|
|
206
|
+
}
|
|
207
|
+
// The only POST in the read-only skill: it lists the companies this token can reach, and
|
|
208
|
+
// is the documented way to discover a company id. It changes nothing.
|
|
209
|
+
const { status, json } = await http("https://api.kleer.se/v1/access/authenticate", {
|
|
210
|
+
method: "POST",
|
|
211
|
+
headers: { "X-Token": token, "content-type": "application/json" },
|
|
212
|
+
});
|
|
213
|
+
if (status === 200) {
|
|
214
|
+
const companies = json?.["accessible-companies"];
|
|
215
|
+
const count = Array.isArray(companies)
|
|
216
|
+
? companies.length
|
|
217
|
+
: Array.isArray(companies?.["accessible-companies"])
|
|
218
|
+
? companies["accessible-companies"].length
|
|
219
|
+
: "?";
|
|
220
|
+
return ok("KLEER_API_TOKEN", `${count} companies reachable`);
|
|
221
|
+
}
|
|
222
|
+
return fail("KLEER_API_TOKEN", json?.message ? `${json.message} (${status})` : `authenticate → ${status}`);
|
|
223
|
+
}
|
|
224
|
+
|
|
167
225
|
async function checkCloudflare() {
|
|
168
226
|
if (!env.CLOUDFLARE_API_KEY || !env.CLOUDFLARE_ACCOUNT_ID) {
|
|
169
227
|
return skip("CLOUDFLARE", "CLOUDFLARE_API_KEY/ACCOUNT_ID not set");
|
|
@@ -466,8 +524,11 @@ async function checkGa4() {
|
|
|
466
524
|
}
|
|
467
525
|
|
|
468
526
|
async function checkGoogleAds() {
|
|
469
|
-
|
|
470
|
-
|
|
527
|
+
// Any world's customer id proves the developer token and the manager link; which world
|
|
528
|
+
// is this deployment's business, so take the first one it declares.
|
|
529
|
+
const customerId = Object.entries(env).find(([k, v]) => k.endsWith("_GOOGLE_ADS_CUSTOMER_ID") && v)?.[1];
|
|
530
|
+
if (!env.GOOGLE_ADS_DEVELOPER_TOKEN || !customerId) {
|
|
531
|
+
return skip("GOOGLE ADS", "developer token / <WORLD>_GOOGLE_ADS_CUSTOMER_ID not set");
|
|
471
532
|
}
|
|
472
533
|
if (!onCloudRun) {
|
|
473
534
|
return skip("GOOGLE ADS", "needs the metadata server — run on Cloud Run");
|
|
@@ -483,7 +544,7 @@ async function checkGoogleAds() {
|
|
|
483
544
|
headers["login-customer-id"] = env.GOOGLE_ADS_LOGIN_CUSTOMER_ID;
|
|
484
545
|
}
|
|
485
546
|
const { status, json } = await http(
|
|
486
|
-
`https://googleads.googleapis.com/v25/customers/${
|
|
547
|
+
`https://googleads.googleapis.com/v25/customers/${customerId}/googleAds:searchStream`,
|
|
487
548
|
{ method: "POST", headers, body: JSON.stringify({ query: "SELECT customer.id FROM customer LIMIT 1" }) },
|
|
488
549
|
);
|
|
489
550
|
status === 200
|
|
@@ -512,9 +573,54 @@ async function checkDeploymentsContext() {
|
|
|
512
573
|
: fail("Deployments context (run.viewer)", `list → ${status}`);
|
|
513
574
|
}
|
|
514
575
|
|
|
576
|
+
/**
|
|
577
|
+
* The handful of credentials without which there is no agent at all — as opposed to an
|
|
578
|
+
* integration being unavailable, which degrades one job. A deployment can be missing every
|
|
579
|
+
* other credential here and still work; missing one of these and it is simply down.
|
|
580
|
+
*
|
|
581
|
+
* These are also the ones a shipped script CAN test properly, because they are the
|
|
582
|
+
* engine's own and mean the same thing in every deployment.
|
|
583
|
+
*/
|
|
584
|
+
const ESSENTIAL = new Set(["AGENT_API_SECRET", "CLAUDE_CODE_OAUTH_TOKEN", "GITHUB_TOKEN", "GIT_REPO_URL"]);
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Inside a run, three of the four are proven by the fact that this output exists at all —
|
|
588
|
+
* a stronger check than any probe could be. The run is happening, so the Claude credential
|
|
589
|
+
* works; the working directory is the content clone, so GIT_REPO_URL and the token that
|
|
590
|
+
* cloned it work; the request that started it was authenticated, so the API secret works.
|
|
591
|
+
*/
|
|
592
|
+
async function checkEssentials() {
|
|
593
|
+
const insideRun = Boolean(env.AGENT_SPAWN_TOKEN);
|
|
594
|
+
if (!env.GIT_REPO_URL) {
|
|
595
|
+
fail("GIT_REPO_URL", "unset — the service has no content repo to clone, and will not boot");
|
|
596
|
+
} else if (insideRun) {
|
|
597
|
+
ok("GIT_REPO_URL", "this run's own clone came from it");
|
|
598
|
+
} else {
|
|
599
|
+
ok("GIT_REPO_URL", "set (not verifiable from here)");
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
515
603
|
function checkPresence() {
|
|
516
|
-
|
|
517
|
-
|
|
604
|
+
// The token that authorises the whole deployment, and the one credential this check
|
|
605
|
+
// cannot see when it matters most. Run inside a job, `claude` strips its own credential
|
|
606
|
+
// from the environment of every subprocess it spawns — a run cannot read the token that
|
|
607
|
+
// started it, which is exactly right, and means "unset" here would be a lie: the run
|
|
608
|
+
// doing the reporting is itself the proof it is set. AGENT_SPAWN_TOKEN is how we know we
|
|
609
|
+
// are inside a run; the engine sets that one, so `claude` has no reason to strip it.
|
|
610
|
+
if (env.CLAUDE_CODE_OAUTH_TOKEN) {
|
|
611
|
+
ok("CLAUDE_CODE_OAUTH_TOKEN", "present (not remotely testable)");
|
|
612
|
+
} else if (env.AGENT_SPAWN_TOKEN) {
|
|
613
|
+
ok("CLAUDE_CODE_OAUTH_TOKEN", "in use by this very run — a run cannot read it, by design");
|
|
614
|
+
} else {
|
|
615
|
+
skip("CLAUDE_CODE_OAUTH_TOKEN", "unset");
|
|
616
|
+
}
|
|
617
|
+
// Either name satisfies the engine; say which one is actually in play.
|
|
618
|
+
if (env.AGENT_API_SECRET) {
|
|
619
|
+
ok("AGENT_API_SECRET", "present (not remotely testable)");
|
|
620
|
+
} else if (env.AGENT_WEBHOOK_SECRET) {
|
|
621
|
+
ok("AGENT_API_SECRET", "present under the old name AGENT_WEBHOOK_SECRET");
|
|
622
|
+
} else {
|
|
623
|
+
skip("AGENT_API_SECRET", "unset");
|
|
518
624
|
}
|
|
519
625
|
}
|
|
520
626
|
|
|
@@ -535,11 +641,12 @@ const CHECKS = [
|
|
|
535
641
|
["GITHUB_TOKEN", checkGithub],
|
|
536
642
|
["GRAFANA (Loki)", checkGrafana],
|
|
537
643
|
["RESEND_API_KEY", checkResend],
|
|
538
|
-
["
|
|
539
|
-
["SWEEPOS_POSTGRES_URL_READONLY", checkPostgres],
|
|
644
|
+
["POSTGRES (read-only)", checkPostgres],
|
|
540
645
|
["POSTHOG", checkPosthog],
|
|
541
646
|
["AHREFS_API_KEY", checkAhrefs],
|
|
542
647
|
["SERPER_API_KEY", checkSerper],
|
|
648
|
+
["HUBSPOT_TOKEN", checkHubspot],
|
|
649
|
+
["KLEER_API_TOKEN", checkKleer],
|
|
543
650
|
["CLOUDFLARE", checkCloudflare],
|
|
544
651
|
["ELEVENLABS_API_KEY", checkElevenlabs],
|
|
545
652
|
["GOOGLE_API_KEY (Places)", checkPlaces],
|
|
@@ -550,6 +657,7 @@ const CHECKS = [
|
|
|
550
657
|
["DWD drive+docs+slides", checkDwdDrive],
|
|
551
658
|
["DWD calendar (write scope)", checkDwdCalendar],
|
|
552
659
|
["SEARCH CONSOLE", checkSearchConsole],
|
|
660
|
+
["Essentials", checkEssentials],
|
|
553
661
|
["GA4", checkGa4],
|
|
554
662
|
["GOOGLE ADS", checkGoogleAds],
|
|
555
663
|
["Deployments context (run.viewer)", checkDeploymentsContext],
|
|
@@ -561,16 +669,79 @@ await Promise.all(
|
|
|
561
669
|
);
|
|
562
670
|
|
|
563
671
|
results.sort((a, b) => a.name.localeCompare(b.name));
|
|
564
|
-
|
|
672
|
+
|
|
673
|
+
// Configured surfaces get a line each. Unconfigured ones get a single line between them,
|
|
674
|
+
// because this script ships to EVERY deployment and knows every integration the engine
|
|
675
|
+
// supports — which is always more than any one deployment uses. Listing them individually
|
|
676
|
+
// asks the operator "why is Kleer in my report?", and the honest answer is that it is not
|
|
677
|
+
// theirs and never was. The deployment's own register of what it can reach is
|
|
678
|
+
// ENVIRONMENT.md in its content repo; the catalogue of what it could add is `connect`.
|
|
679
|
+
// Credentials this script has never heard of.
|
|
680
|
+
//
|
|
681
|
+
// A deployment adds its own systems, and their keys are just secrets bound to the service:
|
|
682
|
+
// ACME_CRM_TOKEN means nothing to an engine that ships to everyone. Testing one needs its
|
|
683
|
+
// endpoint, its auth header and a notion of what a good answer looks like — none of which
|
|
684
|
+
// can be in here. Silence would be the wrong answer though, because "26 working" reads as
|
|
685
|
+
// "everything works" while a set-but-never-exercised key sits beside it.
|
|
686
|
+
//
|
|
687
|
+
// So: name them, say plainly that nothing here can test them, and let the run that called
|
|
688
|
+
// this reconcile them against ENVIRONMENT.md — the deployment's own register does know
|
|
689
|
+
// what they are and which skill reaches them.
|
|
690
|
+
//
|
|
691
|
+
// Known-ness is decided from this file's own source, so the list maintains itself: if the
|
|
692
|
+
// script mentions the variable, or the family it belongs to, it knows about it.
|
|
693
|
+
// Comments stripped first: a variable named in a comment — including the example two
|
|
694
|
+
// paragraphs up — is not a variable this script can test, and counting it as known is how
|
|
695
|
+
// a real unknown gets silently swallowed.
|
|
696
|
+
const selfSource = readFileSync(fileURLToPath(import.meta.url), "utf8")
|
|
697
|
+
.split("\n")
|
|
698
|
+
.filter((line) => !line.trim().startsWith("//"))
|
|
699
|
+
.join("\n");
|
|
700
|
+
const CREDENTIAL_SHAPED = /_(TOKEN|KEY|SECRET|PASSWORD|CREDENTIALS)$/;
|
|
701
|
+
const unknownCredentials = Object.keys(env)
|
|
702
|
+
.filter((name) => CREDENTIAL_SHAPED.test(name))
|
|
703
|
+
.filter((name) => !selfSource.includes(name) && !selfSource.includes(`${name.split("_")[0]}_`))
|
|
704
|
+
.sort();
|
|
705
|
+
|
|
706
|
+
// An essential can never be "not set up here" — that is the whole point of the tier. Do
|
|
707
|
+
// this BEFORE partitioning, or a promoted failure is counted and never printed.
|
|
708
|
+
for (const missing of results.filter((r) => r.status === "skip" && ESSENTIAL.has(r.name))) {
|
|
709
|
+
missing.status = "FAIL";
|
|
710
|
+
missing.detail = `${missing.detail} — the agent cannot run without it`;
|
|
711
|
+
}
|
|
712
|
+
const configured = results.filter((r) => r.status !== "skip");
|
|
713
|
+
const unconfigured = results.filter((r) => r.status === "skip");
|
|
714
|
+
const pad = Math.max(...configured.map((r) => r.name.length), 1) + 2;
|
|
565
715
|
console.log(
|
|
566
|
-
`\nCredential check — ${onCloudRun ? "Cloud Run (all surfaces)" : "local (metadata-server surfaces skipped)"}
|
|
716
|
+
`\nCredential check — ${onCloudRun ? "Cloud Run (all surfaces)" : "local (metadata-server surfaces skipped)"}`,
|
|
567
717
|
);
|
|
568
|
-
|
|
569
|
-
const icon = r.status === "ok" ? "✅" :
|
|
570
|
-
console.log(
|
|
718
|
+
const line = (r) => {
|
|
719
|
+
const icon = r.status === "ok" ? "✅" : "❌";
|
|
720
|
+
console.log(` ${icon} ${r.name.padEnd(pad)} ${r.status === "ok" ? r.detail : r.detail && `— ${r.detail}`}`);
|
|
721
|
+
};
|
|
722
|
+
// Split, because a failure in the first group is an outage and a failure in the second is
|
|
723
|
+
// one job doing less. Reading them in one list makes those look like the same news.
|
|
724
|
+
const essential = configured.filter((r) => ESSENTIAL.has(r.name));
|
|
725
|
+
const integrations = configured.filter((r) => !ESSENTIAL.has(r.name));
|
|
726
|
+
if (essential.length) {
|
|
727
|
+
console.log("\nWithout these there is no agent at all");
|
|
728
|
+
essential.forEach(line);
|
|
729
|
+
}
|
|
730
|
+
if (integrations.length) {
|
|
731
|
+
console.log("\nWhat it can reach");
|
|
732
|
+
integrations.forEach(line);
|
|
571
733
|
}
|
|
572
734
|
const failures = results.filter((r) => r.status === "FAIL");
|
|
573
|
-
console.log(
|
|
574
|
-
|
|
575
|
-
|
|
735
|
+
console.log(`\n${configured.length - failures.length} working, ${failures.length} failing.`);
|
|
736
|
+
if (unknownCredentials.length) {
|
|
737
|
+
// Names only. This script never prints a value, and that holds hardest for the ones it
|
|
738
|
+
// does not understand.
|
|
739
|
+
console.log(`\nSet, but nothing here knows how to test them (${unknownCredentials.length}):`);
|
|
740
|
+
console.log(` ${unknownCredentials.join(", ")}`);
|
|
741
|
+
console.log(" Each should have a row in ENVIRONMENT.md saying which skill reaches it.");
|
|
742
|
+
}
|
|
743
|
+
if (unconfigured.length) {
|
|
744
|
+
console.log(`\nNot set up here (${unconfigured.length}): ${unconfigured.map((r) => r.name).join(", ")}.`);
|
|
745
|
+
console.log("The engine ships more integrations than any deployment uses — `connect` lists them.");
|
|
746
|
+
}
|
|
576
747
|
process.exit(failures.length ? 1 : 0);
|