@meffecta/agent 1.0.2 → 1.0.3
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 +1 -1
- package/bin/meffecta-agent.js +2 -2
- package/deployment.env.example +1 -1
- package/engine.json +2 -2
- package/lib/commands.js +41 -4
- package/lib/doctor.js +16 -1
- package/lib/gcloud.js +17 -13
- package/lib/integrations.js +51 -0
- package/package.json +1 -1
- package/scripts/deploy.sh +1 -1
- package/scripts/set-secret.sh +1 -1
- package/scripts/setup-infrastructure.sh +1 -1
- package/scripts/setup-scheduler.sh +20 -14
- package/scripts/verify-credentials.mjs +53 -1
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ deployment is, so a command can never quietly act on the wrong one.
|
|
|
23
23
|
npx @meffecta/agent create-project # a GCP project
|
|
24
24
|
npx @meffecta/agent link-billing # attach billing
|
|
25
25
|
npx @meffecta/agent setup-infra # registry, buckets, service account, service
|
|
26
|
-
npx @meffecta/agent set-secret
|
|
26
|
+
npx @meffecta/agent set-secret AGENT_API_SECRET --random
|
|
27
27
|
npx @meffecta/agent set-env GIT_REPO_URL=https://github.com/you/your-content-repo.git
|
|
28
28
|
npx @meffecta/agent deploy # roll out the engine
|
|
29
29
|
npx @meffecta/agent setup-scheduler # give it its triggers
|
package/bin/meffecta-agent.js
CHANGED
|
@@ -32,10 +32,10 @@ const STEPS = [
|
|
|
32
32
|
],
|
|
33
33
|
[
|
|
34
34
|
"init",
|
|
35
|
-
"Create your content repo — jobs/, worlds/, SYSTEM.md — and a deployment.env
|
|
35
|
+
"Create your content repo — jobs/, worlds/, SYSTEM.md, ENVIRONMENT.md — and a deployment.env\n in it. Run everything from there; it is what says which deployment you mean.",
|
|
36
36
|
],
|
|
37
37
|
["setup-infra", "Provision GCP and the Cloud Run service shell."],
|
|
38
|
-
["set-secret", "
|
|
38
|
+
["set-secret", "AGENT_API_SECRET --random, CLAUDE_CODE_OAUTH_TOKEN, GITHUB_TOKEN."],
|
|
39
39
|
["set-env", "GIT_REPO_URL=… — the service will not boot without it."],
|
|
40
40
|
["deploy", "Roll out the engine image."],
|
|
41
41
|
["setup-scheduler", "Give it its triggers. Scaled to zero, it fires nothing without them."],
|
package/deployment.env.example
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
#
|
|
11
11
|
# Then operate from the content repo, running the engine's scripts by path:
|
|
12
12
|
# cd /path/to/your-content-repo
|
|
13
|
-
# /path/to/agent/scripts/set-secret.sh
|
|
13
|
+
# /path/to/agent/scripts/set-secret.sh AGENT_API_SECRET --random
|
|
14
14
|
# /path/to/agent/scripts/deploy.sh
|
|
15
15
|
|
|
16
16
|
# The GCP *project ID* — check it with `gcloud projects list`, as the console may have
|
package/engine.json
CHANGED
package/lib/commands.js
CHANGED
|
@@ -245,7 +245,7 @@ async function envList() {
|
|
|
245
245
|
);
|
|
246
246
|
const entries = svc.spec.template.spec.containers[0].env ?? [];
|
|
247
247
|
if (entries.length === 0) {
|
|
248
|
-
console.log("Nothing set. At minimum this service needs GIT_REPO_URL and
|
|
248
|
+
console.log("Nothing set. At minimum this service needs GIT_REPO_URL and AGENT_API_SECRET.");
|
|
249
249
|
return 0;
|
|
250
250
|
}
|
|
251
251
|
const width = Math.max(...entries.map((e) => e.name.length));
|
|
@@ -281,7 +281,7 @@ async function secretsList() {
|
|
|
281
281
|
);
|
|
282
282
|
if (secrets.length === 0) {
|
|
283
283
|
console.log("No secrets in this project yet. Start with:");
|
|
284
|
-
console.log(" meffecta-agent set-secret
|
|
284
|
+
console.log(" meffecta-agent set-secret AGENT_API_SECRET --random");
|
|
285
285
|
return 0;
|
|
286
286
|
}
|
|
287
287
|
const width = Math.max(...secrets.map((name) => name.length));
|
|
@@ -364,8 +364,31 @@ async function logs(args) {
|
|
|
364
364
|
}
|
|
365
365
|
|
|
366
366
|
/** Start a deployment.env in the current directory, from the template in this package. */
|
|
367
|
+
// Scaffolded by `init` into a new content repo. Deliberately short: an empty table with the
|
|
368
|
+
// columns that matter is a thing an operator fills in, where a long example is a thing they
|
|
369
|
+
// delete.
|
|
370
|
+
const REGISTER_TEMPLATE = `# Deployment environment
|
|
371
|
+
|
|
372
|
+
The register of systems this agent can reach. The agent reads this file **at run time** —
|
|
373
|
+
its skills are shared across deployments and name no deployment's variables, so a skill
|
|
374
|
+
says what kind of credential it needs and comes here for the name.
|
|
375
|
+
|
|
376
|
+
A system that is not listed here is one the agent will report as unavailable.
|
|
377
|
+
|
|
378
|
+
| System | Skill | Variables | Access |
|
|
379
|
+
| --- | --- | --- | --- |
|
|
380
|
+
| | | | |
|
|
381
|
+
|
|
382
|
+
Fill the Access column honestly — "read-only", "may send mail", "may change DNS". It is
|
|
383
|
+
what a person reads when deciding whether a job is safe to let run unattended.
|
|
384
|
+
|
|
385
|
+
Set the values themselves with \`meffecta-agent set-secret <NAME>\` (credentials) or
|
|
386
|
+
\`set-env <NAME>=<value>\` (ids, addresses, URLs); \`meffecta-agent connect\` lists the
|
|
387
|
+
systems the engine already has skills for and walks through each.
|
|
388
|
+
`;
|
|
389
|
+
|
|
367
390
|
async function init(args) {
|
|
368
|
-
const { copyFileSync, existsSync: exists } = await import("node:fs");
|
|
391
|
+
const { copyFileSync, existsSync: exists, writeFileSync } = await import("node:fs");
|
|
369
392
|
const target = resolve(process.cwd(), "deployment.env");
|
|
370
393
|
if (exists(target) && !args.includes("--force")) {
|
|
371
394
|
console.log(`deployment.env already exists here. Leaving it alone (--force to replace).`);
|
|
@@ -383,6 +406,20 @@ async function init(args) {
|
|
|
383
406
|
console.log("Fill in PROJECT and SERVICE — those two are never guessed — then commit it.");
|
|
384
407
|
console.log("It belongs in your content repo, beside jobs/, because which GCP project a");
|
|
385
408
|
console.log("set of jobs runs as is a fact about the deployment and none of it is secret.\n");
|
|
409
|
+
|
|
410
|
+
// The register is read by the agent itself at run time, not just by people: the skills
|
|
411
|
+
// are shared across deployments and name no deployment's variables, so without this file
|
|
412
|
+
// a run knows how a system works and not which variable holds its credential.
|
|
413
|
+
const register = resolve(process.cwd(), "ENVIRONMENT.md");
|
|
414
|
+
if (exists(register)) {
|
|
415
|
+
console.log("ENVIRONMENT.md already exists here. Leaving it alone.\n");
|
|
416
|
+
} else {
|
|
417
|
+
writeFileSync(register, REGISTER_TEMPLATE);
|
|
418
|
+
console.log("✅ Wrote ENVIRONMENT.md here — the register of systems this agent can reach.\n");
|
|
419
|
+
console.log("Add a row per system as you connect one. The agent reads this file at run");
|
|
420
|
+
console.log("time to find out which variable holds which credential, so a system that is");
|
|
421
|
+
console.log("not listed is a system it will report as unavailable.\n");
|
|
422
|
+
}
|
|
386
423
|
console.log("Next: meffecta-agent steps");
|
|
387
424
|
return 0;
|
|
388
425
|
}
|
|
@@ -410,7 +447,7 @@ export const GROUPS = [
|
|
|
410
447
|
"Provision GCP: registry, buckets, service account, service shell",
|
|
411
448
|
script("setup-infrastructure.sh"),
|
|
412
449
|
],
|
|
413
|
-
["set-secret", "
|
|
450
|
+
["set-secret", "AGENT_API_SECRET --random, CLAUDE_CODE_OAUTH_TOKEN, GITHUB_TOKEN", script("set-secret.sh")],
|
|
414
451
|
["set-env", "GIT_REPO_URL — the one setting the service will not boot without", script("set-env.sh")],
|
|
415
452
|
["deploy", "Roll out an engine image and assert the runtime shape", script("deploy.sh")],
|
|
416
453
|
["setup-scheduler", "Create the external triggers — Cloud Scheduler + Cloud Tasks", script("setup-scheduler.sh")],
|
package/lib/doctor.js
CHANGED
|
@@ -188,7 +188,22 @@ export async function doctor() {
|
|
|
188
188
|
.filter(Boolean)
|
|
189
189
|
.map((n) => n.split("/").pop()),
|
|
190
190
|
);
|
|
191
|
-
|
|
191
|
+
// AGENT_WEBHOOK_SECRET is the old name for AGENT_API_SECRET and the engine still reads
|
|
192
|
+
// it, so a deployment on the old one is working, not broken — check the pair, not the
|
|
193
|
+
// preferred name, or doctor fails a healthy service.
|
|
194
|
+
const apiSecretName = env.AGENT_API_SECRET
|
|
195
|
+
? "AGENT_API_SECRET"
|
|
196
|
+
: env.AGENT_WEBHOOK_SECRET
|
|
197
|
+
? "AGENT_WEBHOOK_SECRET"
|
|
198
|
+
: "AGENT_API_SECRET";
|
|
199
|
+
if (apiSecretName === "AGENT_WEBHOOK_SECRET") {
|
|
200
|
+
r.warn(
|
|
201
|
+
"AGENT_WEBHOOK_SECRET is the old name",
|
|
202
|
+
"It guards the whole API, not just webhooks, and is now AGENT_API_SECRET. The engine reads both.",
|
|
203
|
+
"meffecta-agent set-secret AGENT_API_SECRET (paste the same value, deploy, then delete the old one)",
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
for (const name of [apiSecretName, "CLAUDE_CODE_OAUTH_TOKEN", "GITHUB_TOKEN"]) {
|
|
192
207
|
const ref = env[name]?.valueFrom?.secretKeyRef?.name;
|
|
193
208
|
if (!env[name]) {
|
|
194
209
|
r.fail(`${name} not set`, "Required by the engine.", `meffecta-agent set-secret ${name}`);
|
package/lib/gcloud.js
CHANGED
|
@@ -69,27 +69,31 @@ export function serviceUrl(deployment) {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
/**
|
|
72
|
-
* The deployment's
|
|
72
|
+
* The deployment's API secret, straight from Secret Manager into memory. It is never
|
|
73
73
|
* printed, never passed as a process argument, and only ever leaves here as a header.
|
|
74
|
+
*
|
|
75
|
+
* AGENT_WEBHOOK_SECRET is the old name — it guarded webhooks once, then everything else —
|
|
76
|
+
* and the engine still reads it, so this tries both rather than telling a deployment that
|
|
77
|
+
* works fine that its secret is missing.
|
|
74
78
|
*/
|
|
75
|
-
export function
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
"gcloud",
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
throw new UserError(
|
|
83
|
-
"Could not read AGENT_WEBHOOK_SECRET from Secret Manager.\n" +
|
|
84
|
-
"Create it first: meffecta-agent set-secret AGENT_WEBHOOK_SECRET --random",
|
|
85
|
-
);
|
|
79
|
+
export function apiSecret(deployment) {
|
|
80
|
+
for (const name of ["AGENT_API_SECRET", "AGENT_WEBHOOK_SECRET"]) {
|
|
81
|
+
try {
|
|
82
|
+
return capture("gcloud", gcloudArgs(deployment, ["secrets", "versions", "access", "latest", `--secret=${name}`]));
|
|
83
|
+
} catch {
|
|
84
|
+
// Try the other name before deciding there is no secret at all.
|
|
85
|
+
}
|
|
86
86
|
}
|
|
87
|
+
throw new UserError(
|
|
88
|
+
"Could not read AGENT_API_SECRET from Secret Manager (nor the old AGENT_WEBHOOK_SECRET).\n" +
|
|
89
|
+
"Create it first: meffecta-agent set-secret AGENT_API_SECRET --random",
|
|
90
|
+
);
|
|
87
91
|
}
|
|
88
92
|
|
|
89
93
|
/** An authenticated request to the deployment's own API. */
|
|
90
94
|
export async function api(deployment, path, { method = "GET", body, accept } = {}) {
|
|
91
95
|
const url = `${serviceUrl(deployment)}${path}`;
|
|
92
|
-
const headers = { Authorization: `Bearer ${
|
|
96
|
+
const headers = { Authorization: `Bearer ${apiSecret(deployment)}` };
|
|
93
97
|
if (accept) {
|
|
94
98
|
headers.Accept = accept;
|
|
95
99
|
}
|
package/lib/integrations.js
CHANGED
|
@@ -132,6 +132,57 @@ const INTEGRATIONS = {
|
|
|
132
132
|
],
|
|
133
133
|
},
|
|
134
134
|
|
|
135
|
+
hubspot: {
|
|
136
|
+
title: "HubSpot CRM",
|
|
137
|
+
gives: "query-hubspot — pipelines, contacts, deals, activity. manage-hubspot writes, and only when a job says to",
|
|
138
|
+
needs: [
|
|
139
|
+
"A private app in the HubSpot portal: Settings → Integrations → Private Apps → Create.",
|
|
140
|
+
"Grant only the scopes the jobs need — crm.objects.*.read for reporting; add the",
|
|
141
|
+
"matching .write scopes only if a job is meant to change records.",
|
|
142
|
+
],
|
|
143
|
+
steps: [["meffecta-agent set-secret HUBSPOT_TOKEN", "the private app's access token"]],
|
|
144
|
+
more: [
|
|
145
|
+
"A second portal is HUBSPOT_<NAME>_TOKEN, and the job says which portal it may touch.",
|
|
146
|
+
"Reading and writing are two skills on purpose: a reporting job loads query-hubspot and",
|
|
147
|
+
"then cannot alter the CRM at all, because the ability to is not in its context.",
|
|
148
|
+
"Rotate the token from the same screen; HubSpot can expire the old one after 7 days.",
|
|
149
|
+
],
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
kleer: {
|
|
153
|
+
title: "Kleer accounting and payroll",
|
|
154
|
+
gives:
|
|
155
|
+
"query-kleer — invoices out and in, payment status, vouchers, the SIE4E export, payroll runs, bank transactions. Read-only",
|
|
156
|
+
needs: [
|
|
157
|
+
"A Kleer API token, requested through your accounting consultant — Kleer issues it by",
|
|
158
|
+
"SMS, there is no self-service. It inherits the permissions of the Kleer user it is",
|
|
159
|
+
"bound to, so ask for it on a READ-ONLY user: that, not the skill, is what stops a",
|
|
160
|
+
"write. (Kleer was PE Accounting before the rebrand; the docs still say PE in places.)",
|
|
161
|
+
],
|
|
162
|
+
steps: [["meffecta-agent set-secret KLEER_API_TOKEN", "the token Kleer sent"]],
|
|
163
|
+
more: [
|
|
164
|
+
"Company ids are discovered at run time from the token, so nothing else needs setting.",
|
|
165
|
+
"There is a test environment on the same token, mirroring production a day behind —",
|
|
166
|
+
"worth pointing a new job at first, since live books are not a place to explore.",
|
|
167
|
+
],
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
mongodb: {
|
|
171
|
+
title: "A MongoDB database",
|
|
172
|
+
gives: "query-mongodb — counts, filters and aggregations over a product database. Read-only",
|
|
173
|
+
needs: [
|
|
174
|
+
"A MongoDB user with the `read` role on the database and nothing more, and a connection",
|
|
175
|
+
"URI for it. The role is the real boundary — the skill refuses to write, but a",
|
|
176
|
+
"read-only user makes a mistake impossible rather than merely forbidden.",
|
|
177
|
+
],
|
|
178
|
+
steps: [["meffecta-agent set-secret ACME_MONGODB_URI_READONLY", "one per database, named for the project"]],
|
|
179
|
+
more: [
|
|
180
|
+
"Connection strings are per-database, so there is no single shared variable: name each",
|
|
181
|
+
"one for its project and record it in the deployment's ENVIRONMENT.md, which is where",
|
|
182
|
+
"the skill looks for the name. The driver already ships in the engine image.",
|
|
183
|
+
],
|
|
184
|
+
},
|
|
185
|
+
|
|
135
186
|
cloudflare: {
|
|
136
187
|
title: "Cloudflare",
|
|
137
188
|
gives: "cloudflare — DNS, cache purge, Pages deployments, certificate checks",
|
package/package.json
CHANGED
package/scripts/deploy.sh
CHANGED
|
@@ -165,5 +165,5 @@ URL=$(gcloud run services describe "${SERVICE}" \
|
|
|
165
165
|
if [ -n "${URL}" ]; then
|
|
166
166
|
echo "🌍 ${SERVICE} → ${URL}"
|
|
167
167
|
echo " Liveness: curl -fsS ${URL}/health"
|
|
168
|
-
echo " Ask it: ${URL}/ask (any username,
|
|
168
|
+
echo " Ask it: ${URL}/ask (any username, AGENT_API_SECRET as the password)"
|
|
169
169
|
fi
|
package/scripts/set-secret.sh
CHANGED
|
@@ -8,7 +8,7 @@ set -euo pipefail
|
|
|
8
8
|
# The value never appears in your shell history, in the process list, or on screen.
|
|
9
9
|
#
|
|
10
10
|
# Usage:
|
|
11
|
-
# scripts/set-secret.sh
|
|
11
|
+
# scripts/set-secret.sh AGENT_API_SECRET --random # generate one, 32 random bytes
|
|
12
12
|
# scripts/set-secret.sh GITHUB_TOKEN # prompt (hidden), or read stdin
|
|
13
13
|
# scripts/set-secret.sh GMAIL_REFRESH_TOKEN --from-file token.txt
|
|
14
14
|
# cat token.txt | scripts/set-secret.sh GMAIL_REFRESH_TOKEN
|
|
@@ -233,7 +233,7 @@ echo " 3. Create + wire the secrets:"
|
|
|
233
233
|
echo " printf '%s' '<value>' | gcloud secrets create <NAME> --data-file=- --project ${PROJECT}"
|
|
234
234
|
echo " gcloud run services update ${SERVICE} --region ${REGION} --project ${PROJECT} \\"
|
|
235
235
|
echo " --update-secrets '<NAME>=<NAME>:latest'"
|
|
236
|
-
echo " Required:
|
|
236
|
+
echo " Required: AGENT_API_SECRET, CLAUDE_CODE_OAUTH_TOKEN (claude setup-token), GITHUB_TOKEN"
|
|
237
237
|
echo " Gmail: GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET, GMAIL_REFRESH_TOKEN — and one"
|
|
238
238
|
echo " GMAIL_<NAME>_REFRESH_TOKEN per extra account (e.g. GMAIL_MEVISIO_REFRESH_TOKEN)"
|
|
239
239
|
echo " Optional: GRAFANA_LOGS_HOST/USERNAME + GRAFANA_API_KEY"
|
|
@@ -70,8 +70,8 @@ GC="gcloud --project=${PROJECT}"
|
|
|
70
70
|
# arguments of the calls that create them — and gcloud echoes the arguments back when it
|
|
71
71
|
# rejects one. Everything this script prints goes through here first.
|
|
72
72
|
redact() {
|
|
73
|
-
if [ -n "${
|
|
74
|
-
printf '%s\n' "${1//${
|
|
73
|
+
if [ -n "${API_SECRET:-}" ]; then
|
|
74
|
+
printf '%s\n' "${1//${API_SECRET}/<secret>}"
|
|
75
75
|
else
|
|
76
76
|
printf '%s\n' "$1"
|
|
77
77
|
fi
|
|
@@ -119,18 +119,24 @@ fi
|
|
|
119
119
|
|
|
120
120
|
# The triggers authenticate with the same bearer token as everything else on this API, so
|
|
121
121
|
# 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
|
-
|
|
122
|
+
# AGENT_WEBHOOK_SECRET is the old name, still read by the engine and still looked for here,
|
|
123
|
+
# so a deployment part-way through the rename gets working triggers either way.
|
|
124
|
+
API_SECRET=""
|
|
125
|
+
for SECRET_VAR in AGENT_API_SECRET AGENT_WEBHOOK_SECRET; do
|
|
126
|
+
IFS=$'\t' read -r SECRET_KIND SECRET_A SECRET_B <<<"$(printf '%s' "${SERVICE_JSON}" | svc_field "${SECRET_VAR}")"
|
|
127
|
+
if [ "${SECRET_KIND}" = "value" ]; then
|
|
128
|
+
API_SECRET="${SECRET_A}"
|
|
129
|
+
elif [ "${SECRET_KIND}" = "secret" ] && [ -n "${SECRET_A}" ]; then
|
|
130
|
+
API_SECRET=$(${GC} secrets versions access "${SECRET_B}" --secret="${SECRET_A}")
|
|
131
|
+
fi
|
|
132
|
+
[ -n "${API_SECRET}" ] && break
|
|
133
|
+
done
|
|
134
|
+
if [ -z "${API_SECRET}" ]; then
|
|
135
|
+
echo "Could not resolve AGENT_API_SECRET (or the old AGENT_WEBHOOK_SECRET) from ${SERVICE}." >&2
|
|
136
|
+
echo " scripts/set-secret.sh AGENT_API_SECRET --random" >&2
|
|
137
|
+
echo "Refusing to create triggers that would 401 on every firing." >&2
|
|
131
138
|
exit 1
|
|
132
139
|
fi
|
|
133
|
-
[ -n "${WEBHOOK_SECRET}" ] || { echo "AGENT_WEBHOOK_SECRET resolved empty — refusing to create triggers that would 401." >&2; exit 1; }
|
|
134
140
|
|
|
135
141
|
echo " url: ${AGENT_URL}"
|
|
136
142
|
echo " runtime: ${RUNTIME_SA}"
|
|
@@ -171,7 +177,7 @@ if [ "${CURRENT_QUEUE}" != "${QUEUE_PATH}" ] || [ "${CURRENT_URL}" != "${AGENT_U
|
|
|
171
177
|
fi
|
|
172
178
|
|
|
173
179
|
echo "📋 Reading the job list from ${AGENT_URL}/jobs..."
|
|
174
|
-
JOBS_JSON=$(curl -fsS --max-time 300 -H "Authorization: Bearer ${
|
|
180
|
+
JOBS_JSON=$(curl -fsS --max-time 300 -H "Authorization: Bearer ${API_SECRET}" "${AGENT_URL}/jobs") || {
|
|
175
181
|
cat >&2 <<EOF
|
|
176
182
|
Could not read ${AGENT_URL}/jobs.
|
|
177
183
|
|
|
@@ -193,7 +199,7 @@ EOF
|
|
|
193
199
|
CRON_JOBS=$(printf '%s' "${JOBS_JSON}" | node "${READ_JSON}" crons)
|
|
194
200
|
WATCHES_INBOX=$(printf '%s' "${JOBS_JSON}" | node "${READ_JSON}" watches-inbox)
|
|
195
201
|
|
|
196
|
-
HEADERS="Content-Type=application/json,Authorization=Bearer ${
|
|
202
|
+
HEADERS="Content-Type=application/json,Authorization=Bearer ${API_SECRET}"
|
|
197
203
|
|
|
198
204
|
# `create http` takes --headers; `update http` insists on --update-headers for the same
|
|
199
205
|
# thing. Everything else about the two calls is identical.
|
|
@@ -164,6 +164,48 @@ async function checkSerper() {
|
|
|
164
164
|
return fail("SERPER_API_KEY", json?.message ? `${json.message} (${status})` : `search → ${status}`);
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
+
async function checkHubspot() {
|
|
168
|
+
const token = env.HUBSPOT_TOKEN;
|
|
169
|
+
if (!token) {
|
|
170
|
+
return skip("HUBSPOT_TOKEN", "unset");
|
|
171
|
+
}
|
|
172
|
+
// limit=1 with no properties: the cheapest call that still proves the token and a scope.
|
|
173
|
+
const { status, json } = await http("https://api.hubapi.com/crm/v3/objects/contacts?limit=1", {
|
|
174
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
175
|
+
});
|
|
176
|
+
if (status === 200) {
|
|
177
|
+
return ok("HUBSPOT_TOKEN", "CRM readable");
|
|
178
|
+
}
|
|
179
|
+
// 403 is a granted-scopes problem, not a bad token — different fix, so say which.
|
|
180
|
+
return fail(
|
|
181
|
+
"HUBSPOT_TOKEN",
|
|
182
|
+
status === 403 ? "token valid but missing CRM read scope (403)" : `contacts → ${status}`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function checkKleer() {
|
|
187
|
+
const token = env.KLEER_API_TOKEN;
|
|
188
|
+
if (!token) {
|
|
189
|
+
return skip("KLEER_API_TOKEN", "unset");
|
|
190
|
+
}
|
|
191
|
+
// The only POST in the read-only skill: it lists the companies this token can reach, and
|
|
192
|
+
// is the documented way to discover a company id. It changes nothing.
|
|
193
|
+
const { status, json } = await http("https://api.kleer.se/v1/access/authenticate", {
|
|
194
|
+
method: "POST",
|
|
195
|
+
headers: { "X-Token": token, "content-type": "application/json" },
|
|
196
|
+
});
|
|
197
|
+
if (status === 200) {
|
|
198
|
+
const companies = json?.["accessible-companies"];
|
|
199
|
+
const count = Array.isArray(companies)
|
|
200
|
+
? companies.length
|
|
201
|
+
: Array.isArray(companies?.["accessible-companies"])
|
|
202
|
+
? companies["accessible-companies"].length
|
|
203
|
+
: "?";
|
|
204
|
+
return ok("KLEER_API_TOKEN", `${count} companies reachable`);
|
|
205
|
+
}
|
|
206
|
+
return fail("KLEER_API_TOKEN", json?.message ? `${json.message} (${status})` : `authenticate → ${status}`);
|
|
207
|
+
}
|
|
208
|
+
|
|
167
209
|
async function checkCloudflare() {
|
|
168
210
|
if (!env.CLOUDFLARE_API_KEY || !env.CLOUDFLARE_ACCOUNT_ID) {
|
|
169
211
|
return skip("CLOUDFLARE", "CLOUDFLARE_API_KEY/ACCOUNT_ID not set");
|
|
@@ -513,9 +555,17 @@ async function checkDeploymentsContext() {
|
|
|
513
555
|
}
|
|
514
556
|
|
|
515
557
|
function checkPresence() {
|
|
516
|
-
for (const k of ["CLAUDE_CODE_OAUTH_TOKEN"
|
|
558
|
+
for (const k of ["CLAUDE_CODE_OAUTH_TOKEN"]) {
|
|
517
559
|
env[k] ? ok(k, "present (not remotely testable)") : skip(k, "unset");
|
|
518
560
|
}
|
|
561
|
+
// Either name satisfies the engine; say which one is actually in play.
|
|
562
|
+
if (env.AGENT_API_SECRET) {
|
|
563
|
+
ok("AGENT_API_SECRET", "present (not remotely testable)");
|
|
564
|
+
} else if (env.AGENT_WEBHOOK_SECRET) {
|
|
565
|
+
ok("AGENT_API_SECRET", "present under the old name AGENT_WEBHOOK_SECRET");
|
|
566
|
+
} else {
|
|
567
|
+
skip("AGENT_API_SECRET", "unset");
|
|
568
|
+
}
|
|
519
569
|
}
|
|
520
570
|
|
|
521
571
|
// --- Run ---------------------------------------------------------------------
|
|
@@ -540,6 +590,8 @@ const CHECKS = [
|
|
|
540
590
|
["POSTHOG", checkPosthog],
|
|
541
591
|
["AHREFS_API_KEY", checkAhrefs],
|
|
542
592
|
["SERPER_API_KEY", checkSerper],
|
|
593
|
+
["HUBSPOT_TOKEN", checkHubspot],
|
|
594
|
+
["KLEER_API_TOKEN", checkKleer],
|
|
543
595
|
["CLOUDFLARE", checkCloudflare],
|
|
544
596
|
["ELEVENLABS_API_KEY", checkElevenlabs],
|
|
545
597
|
["GOOGLE_API_KEY (Places)", checkPlaces],
|