@meffecta/agent 1.0.3 → 1.0.8
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 +85 -70
- package/bin/meffecta-agent.js +65 -13
- package/deployment.env.example +24 -11
- package/engine.json +2 -2
- package/lib/analytics.js +262 -0
- package/lib/commands.js +225 -40
- package/lib/create-job.js +344 -0
- package/lib/doctor.js +20 -7
- package/lib/gcloud.js +25 -0
- package/lib/integrations.js +3 -3
- package/lib/resources.js +300 -0
- package/lib/verify-credentials.js +129 -0
- package/lib/version.js +90 -0
- package/package.json +4 -2
- package/scripts/create-project.sh +3 -1
- package/scripts/deploy.sh +19 -8
- 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 +1 -1
- package/scripts/setup-infrastructure.sh +187 -100
- package/scripts/{setup-scheduler.sh → sync-triggers.sh} +12 -16
- package/scripts/update-tooling.sh +3 -1
- package/scripts/verify-credentials.mjs +168 -49
package/README.md
CHANGED
|
@@ -1,94 +1,109 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Meffecta Agent
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
An AI colleague that runs in **your own Google Cloud**, on **your own jobs**, against your
|
|
4
|
+
own systems. It wakes on a schedule, a webhook, or an email, does a piece of real work, and
|
|
5
|
+
reports back — then goes back to sleep costing nothing.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
and nothing here depends on it.
|
|
7
|
+
Work arrives finished, rather than waiting in a chat window for you to open it — though
|
|
8
|
+
you can also just ask it something when you want to.
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
npx @meffecta/agent steps # the whole set-up, in order
|
|
13
|
-
npx @meffecta/agent help # every command
|
|
14
|
-
```
|
|
10
|
+
### → [agent.meffecta.com](https://agent.meffecta.com)
|
|
15
11
|
|
|
16
|
-
|
|
12
|
+
What it is, what you need, and the whole set-up in order. This package (`@meffecta/agent`)
|
|
13
|
+
is the tool that stands one up and operates it.
|
|
17
14
|
|
|
18
|
-
|
|
19
|
-
`worlds/` and `SYSTEM.md`. That file says which GCP project and Cloud Run service this
|
|
20
|
-
deployment is, so a command can never quietly act on the wrong one.
|
|
15
|
+
## What people run it for
|
|
21
16
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
npx @meffecta/agent link-billing # attach billing
|
|
25
|
-
npx @meffecta/agent setup-infra # registry, buckets, service account, service
|
|
26
|
-
npx @meffecta/agent set-secret AGENT_API_SECRET --random
|
|
27
|
-
npx @meffecta/agent set-env GIT_REPO_URL=https://github.com/you/your-content-repo.git
|
|
28
|
-
npx @meffecta/agent deploy # roll out the engine
|
|
29
|
-
npx @meffecta/agent setup-scheduler # give it its triggers
|
|
30
|
-
```
|
|
17
|
+
A job is a page of plain-language instructions, so this is a sample rather than a menu —
|
|
18
|
+
the agent does what your jobs say.
|
|
31
19
|
|
|
32
|
-
|
|
33
|
-
|
|
20
|
+
**Sales** — find prospects and check them against your CRM, research each company and its
|
|
21
|
+
decision-maker, leave outreach drafts in the seller's own mailbox for them to send, and put
|
|
22
|
+
a next step on every priority account.
|
|
34
23
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
```
|
|
24
|
+
**Marketing** — one weekly read across traffic, search, ads and campaigns. Website and
|
|
25
|
+
search audits with fixes proposed. Ad reviews, where changes happen only on request. Watches
|
|
26
|
+
form leads, contacts, and competitors' rankings.
|
|
39
27
|
|
|
40
|
-
|
|
28
|
+
**Customer care** — a support inbox that answers or hands over with the findings attached,
|
|
29
|
+
learns from the conversations, and keeps help pages current as things change.
|
|
41
30
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
npx @meffecta/agent env # every setting (secret values never printed)
|
|
47
|
-
npx @meffecta/agent secrets # what is stored, and what the service reads
|
|
48
|
-
npx @meffecta/agent triggers # the Cloud Scheduler jobs and task queue
|
|
49
|
-
npx @meffecta/agent logs --limit 100
|
|
50
|
-
npx @meffecta/agent run morning-brief
|
|
51
|
-
npx @meffecta/agent ask "how did search do last week"
|
|
52
|
-
```
|
|
31
|
+
**Compliance** — review an ISO 27001 document set against the standard, find what is overdue
|
|
32
|
+
or contradicts itself, and draft revisions for a person to approve — sized to your business
|
|
33
|
+
rather than to a template's idea of one. It never records that a control is in place: that
|
|
34
|
+
claim is what an audit tests.
|
|
53
35
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
with no triggers runs nothing while looking perfectly healthy, an always-on one with
|
|
57
|
-
triggers runs every cron twice, a sweep whose cadence no longer matches the inbox poll
|
|
58
|
-
interval checks mail on yesterday's schedule for ever, and a registry that expired the
|
|
59
|
-
image your revision pins will fail at the next cold start rather than the next deploy.
|
|
60
|
-
Every finding carries the command that fixes it, and it exits non-zero on a real problem
|
|
61
|
-
so CI can run it.
|
|
36
|
+
**Engineering** — overnight error triage with fixes proposed as pull requests, a verdict on
|
|
37
|
+
every proposed change, alerts traced to a likely cause, and weekly upkeep.
|
|
62
38
|
|
|
63
|
-
|
|
39
|
+
**Leadership** — a morning brief tailored to each person's role, daily status on what is
|
|
40
|
+
live and in use, and weekly reviews that stay honest about flat weeks.
|
|
64
41
|
|
|
65
|
-
**
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
42
|
+
**Assistant** — email it anything: a lookup, a draft, a booking, a question. One calendar
|
|
43
|
+
across Google and Microsoft 365. Inbox digests and the week ahead.
|
|
44
|
+
|
|
45
|
+
## What it can reach
|
|
69
46
|
|
|
70
|
-
|
|
71
|
-
reads it; `env` names secret-backed settings without resolving them. Setting one takes the
|
|
72
|
-
value from a pipe, a file, or a hidden prompt — never from your shell history.
|
|
47
|
+
Shipped with the engine, pointed at your accounts by your jobs:
|
|
73
48
|
|
|
74
|
-
|
|
49
|
+
| | Reached out of the box |
|
|
50
|
+
|---|---|
|
|
51
|
+
| **Mail & calendar** | Gmail, Microsoft 365, Google Calendar |
|
|
52
|
+
| **Documents** | Drive, Sheets, Docs, Slides |
|
|
53
|
+
| **Your numbers** | GA4, Search Console, Google Ads, PostHog, Ahrefs |
|
|
54
|
+
| **Your data** | BigQuery, Elasticsearch, MongoDB — and logs from Grafana or Coralogix |
|
|
55
|
+
| **The market** | Google's live search results — real rankings, not a web search |
|
|
56
|
+
| **CRM** | HubSpot, reading and writing as separate abilities |
|
|
57
|
+
| **Work & content** | Linear issues and cycles; Webflow pages and CMS, staged before anything goes public |
|
|
58
|
+
| **Slack** | Posts, threads and reactions — and answers when someone @-mentions it |
|
|
59
|
+
| **Infrastructure** | Cloudflare, and a real headless browser for anything without an API |
|
|
60
|
+
| **Your code** | any Git repo as the working directory, with pull requests as the output |
|
|
75
61
|
|
|
76
|
-
|
|
77
|
-
Node 20+ — Node 24+ if you also run the `claude` CLI to mint the token. macOS or Linux.
|
|
62
|
+
Anything else is a skill you add to your own repo — the engine never needs to know about it.
|
|
78
63
|
|
|
79
|
-
##
|
|
64
|
+
## How it works
|
|
80
65
|
|
|
81
|
-
|
|
66
|
+
Your **content repo** holds the jobs, the facts about your business, and the register of
|
|
67
|
+
which systems this deployment may touch. Every run clones it fresh. Push a changed prompt
|
|
68
|
+
and the next run uses it; there is nothing to build and nothing to deploy.
|
|
69
|
+
|
|
70
|
+
Jobs are triggered by a **schedule**, a **webhook**, or **an email to an address you name**
|
|
71
|
+
— and a job that runs out of time can hand the rest to a follow-up run of itself. Each job
|
|
72
|
+
keeps its own memory across runs, and every prompt, result and transcript lands in a
|
|
73
|
+
write-once audit bucket.
|
|
74
|
+
|
|
75
|
+
## Built to be trusted with the keys
|
|
76
|
+
|
|
77
|
+
It runs in **your** GCP project, on **your** credentials, billed to **your** Claude
|
|
78
|
+
subscription. Nothing routes through Meffecta.
|
|
79
|
+
|
|
80
|
+
A job reaches only the systems your register lists, and reading and changing are separate
|
|
81
|
+
abilities — a reporting job loads the reader and never the writer.
|
|
82
|
+
|
|
83
|
+
One shipped skill exists to change a paid account, Google Ads, and it is the most
|
|
84
|
+
constrained thing here: every change is validated against the live account first and only
|
|
85
|
+
applied on a second, explicit run; budgets can only go *down*; a campaign can be paused but
|
|
86
|
+
never deleted; and the limits live in the script, not in the instructions, so no wording can
|
|
87
|
+
talk its way past them.
|
|
88
|
+
|
|
89
|
+
A job reachable by email must say who may reach it, or it refuses everyone — an omission is
|
|
90
|
+
not consent. Secret values are never printed, by any command.
|
|
91
|
+
|
|
92
|
+
## Operating it
|
|
82
93
|
|
|
83
94
|
```bash
|
|
84
|
-
|
|
95
|
+
npx @meffecta/agent steps # the set-up, in order
|
|
96
|
+
npx @meffecta/agent help # every command — each also takes --help
|
|
97
|
+
npx @meffecta/agent doctor # the one to run when something feels wrong
|
|
85
98
|
```
|
|
86
99
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
100
|
+
Run commands from your content repo — the directory holding `deployment.env`, which says
|
|
101
|
+
which project and service this deployment is, so a command can never act on the wrong one.
|
|
102
|
+
|
|
103
|
+
**The version you install is the engine you deploy.** Each release records the engine build
|
|
104
|
+
published alongside it, and `deploy` defaults to that image, so tooling and engine cannot
|
|
105
|
+
drift apart. `meffecta-agent version` tells you which pair you have.
|
|
90
106
|
|
|
91
|
-
##
|
|
107
|
+
## Licence
|
|
92
108
|
|
|
93
|
-
|
|
94
|
-
set-up in order, with the command for each step. Every command takes `--help`.
|
|
109
|
+
UNLICENSED — for use by Meffecta deployments.
|
package/bin/meffecta-agent.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { open as openAnalytics } from "../lib/analytics.js";
|
|
2
3
|
import { COMMANDS, cliVersion, engineTag, GROUPS } from "../lib/commands.js";
|
|
3
4
|
import { loadDeployment, UserError } from "../lib/config.js";
|
|
5
|
+
import { installMethod } from "../lib/version.js";
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* One entry point for everything an operator does to a Meffecta Agent deployment.
|
|
@@ -32,13 +34,15 @@ const STEPS = [
|
|
|
32
34
|
],
|
|
33
35
|
[
|
|
34
36
|
"init",
|
|
35
|
-
"Create your content repo — jobs/,
|
|
37
|
+
"Create your content repo — jobs/, SYSTEM.md, systems/, and a deployment.env in it.\n Run everything from there; it is what says which deployment you mean.",
|
|
36
38
|
],
|
|
37
|
-
["setup-infra", "Provision GCP and the Cloud Run service shell."],
|
|
39
|
+
["setup-infra", "Provision GCP, the run queue, and the Cloud Run service shell.\n --dry-run shows it first."],
|
|
38
40
|
["set-secret", "AGENT_API_SECRET --random, CLAUDE_CODE_OAUTH_TOKEN, GITHUB_TOKEN."],
|
|
39
41
|
["set-env", "GIT_REPO_URL=… — the service will not boot without it."],
|
|
40
|
-
[
|
|
41
|
-
|
|
42
|
+
[
|
|
43
|
+
"deploy",
|
|
44
|
+
"Roll out the engine image, then create its triggers — scaled to zero it fires\n nothing without them, and only the running engine knows which jobs exist.",
|
|
45
|
+
],
|
|
42
46
|
["doctor", 'Check it. Then: jobs, run <job>, ask "…".'],
|
|
43
47
|
];
|
|
44
48
|
|
|
@@ -60,9 +64,10 @@ function help() {
|
|
|
60
64
|
console.log("");
|
|
61
65
|
}
|
|
62
66
|
console.log(" steps The whole set-up, in order, with the command for each");
|
|
63
|
-
console.log(" version This CLI and the engine build it deploys
|
|
64
|
-
console.log("
|
|
65
|
-
console.log("
|
|
67
|
+
console.log(" version This CLI and the engine build it deploys");
|
|
68
|
+
console.log(" upgrade Whether a newer one is out, and what to run\n");
|
|
69
|
+
console.log("Every command takes --help, and every option it documents — so");
|
|
70
|
+
console.log("`meffecta-agent deploy --dry-run` and `--tag <sha>` work as described there.\n");
|
|
66
71
|
console.log(`Engine: ghcr.io/meffecta/agent:${tag}${pinned ? " (pinned to this release)" : ""}`);
|
|
67
72
|
}
|
|
68
73
|
|
|
@@ -94,17 +99,60 @@ function version() {
|
|
|
94
99
|
|
|
95
100
|
const [name, ...args] = process.argv.slice(2);
|
|
96
101
|
|
|
102
|
+
const { analytics, firstRun } = openAnalytics({ version: cliVersion() });
|
|
103
|
+
|
|
104
|
+
// The name is matched against the known commands before it is ever recorded. Whatever an
|
|
105
|
+
// operator actually typed is theirs — a mistyped command could be anything, including
|
|
106
|
+
// something they would not want leaving the machine.
|
|
107
|
+
const BUILT_IN = new Set(["help", "steps", "version"]);
|
|
108
|
+
function recorded(candidate) {
|
|
109
|
+
if (!candidate) {
|
|
110
|
+
return "help";
|
|
111
|
+
}
|
|
112
|
+
return COMMANDS.has(candidate) || BUILT_IN.has(candidate) ? candidate : "unknown";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const started = Date.now();
|
|
116
|
+
|
|
117
|
+
/** Record the run, flush, and leave. The only exit in this file. */
|
|
118
|
+
async function finish(code, outcome, extra = {}) {
|
|
119
|
+
analytics.capture("cli_command", {
|
|
120
|
+
command: recorded(name),
|
|
121
|
+
outcome,
|
|
122
|
+
exit_code: code,
|
|
123
|
+
duration_ms: Date.now() - started,
|
|
124
|
+
cli_version: cliVersion(),
|
|
125
|
+
engine_tag: engineTag().tag,
|
|
126
|
+
install: installMethod().kind,
|
|
127
|
+
node_version: process.versions.node,
|
|
128
|
+
platform: process.platform,
|
|
129
|
+
arch: process.arch,
|
|
130
|
+
has_deployment: Boolean(loadDeployment().PROJECT),
|
|
131
|
+
...extra,
|
|
132
|
+
});
|
|
133
|
+
await analytics.flush();
|
|
134
|
+
process.exit(code);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Said once, on the run that mints the id, and never again. A tool that phones home should
|
|
138
|
+
// say so on the machine it phones home from — not only in documentation the operator would
|
|
139
|
+
// have to go looking for. stderr, so it cannot corrupt anything reading stdout.
|
|
140
|
+
if (firstRun) {
|
|
141
|
+
console.error("(Anonymous usage analytics are on: which commands run, and whether they worked.");
|
|
142
|
+
console.error(" No project names, arguments or secrets. `meffecta-agent analytics` to see or stop it.)\n");
|
|
143
|
+
}
|
|
144
|
+
|
|
97
145
|
if (!name || name === "help" || name === "--help" || name === "-h") {
|
|
98
146
|
help();
|
|
99
|
-
|
|
147
|
+
await finish(0, "ok");
|
|
100
148
|
}
|
|
101
149
|
if (name === "steps") {
|
|
102
150
|
steps();
|
|
103
|
-
|
|
151
|
+
await finish(0, "ok");
|
|
104
152
|
}
|
|
105
153
|
if (name === "version" || name === "--version" || name === "-v") {
|
|
106
154
|
version();
|
|
107
|
-
|
|
155
|
+
await finish(0, "ok");
|
|
108
156
|
}
|
|
109
157
|
|
|
110
158
|
const command = COMMANDS.get(name);
|
|
@@ -115,7 +163,7 @@ if (!command) {
|
|
|
115
163
|
console.error(`Did you mean: ${near.join(", ")}?`);
|
|
116
164
|
}
|
|
117
165
|
console.error("Run `meffecta-agent help` for the list.");
|
|
118
|
-
|
|
166
|
+
await finish(2, "unknown-command");
|
|
119
167
|
}
|
|
120
168
|
|
|
121
169
|
try {
|
|
@@ -128,11 +176,15 @@ try {
|
|
|
128
176
|
args.push("--tag", tag);
|
|
129
177
|
}
|
|
130
178
|
}
|
|
131
|
-
|
|
179
|
+
const code = (await command.handler(args)) ?? 0;
|
|
180
|
+
await finish(code, code === 0 ? "ok" : "failed");
|
|
132
181
|
} catch (err) {
|
|
133
182
|
if (err instanceof UserError) {
|
|
134
183
|
console.error(err.message);
|
|
135
|
-
|
|
184
|
+
await finish(1, "user-error");
|
|
136
185
|
}
|
|
186
|
+
// The CLASS, never the message: messages quote project ids, paths and gcloud output.
|
|
187
|
+
analytics.capture("cli_crash", { command: recorded(name), error_kind: err?.constructor?.name ?? "Error" });
|
|
188
|
+
await analytics.flush();
|
|
137
189
|
throw err;
|
|
138
190
|
}
|
package/deployment.env.example
CHANGED
|
@@ -5,13 +5,13 @@
|
|
|
5
5
|
# in Secret Manager), and a fresh clone of the content repo is then everything an
|
|
6
6
|
# operator needs.
|
|
7
7
|
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
8
|
+
# Start one there:
|
|
9
|
+
# cd /path/to/your-content-repo && meffecta-agent init
|
|
10
10
|
#
|
|
11
|
-
# Then
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
11
|
+
# Then run everything from that directory — each command reads this file to know which
|
|
12
|
+
# deployment you mean:
|
|
13
|
+
# meffecta-agent set-secret AGENT_API_SECRET --random
|
|
14
|
+
# meffecta-agent deploy
|
|
15
15
|
|
|
16
16
|
# The GCP *project ID* — check it with `gcloud projects list`, as the console may have
|
|
17
17
|
# suffixed it and it need not match the project's display name.
|
|
@@ -23,6 +23,17 @@ SERVICE=acme-agent
|
|
|
23
23
|
# Docker repo in that project, holding images mirrored from GHCR.
|
|
24
24
|
ARTIFACT_REPO=acme-agent-images
|
|
25
25
|
|
|
26
|
+
# How the engine image reaches Cloud Run, which can only pull from Artifact Registry.
|
|
27
|
+
# proxy (default) ARTIFACT_REPO is a remote repository in front of ghcr.io. AR fetches
|
|
28
|
+
# the public engine image on demand — nothing is copied, and no docker is needed
|
|
29
|
+
# on your machine at any point.
|
|
30
|
+
# mirror the older path: pull from ghcr.io here, retag, push. Needs docker running and
|
|
31
|
+
# `gcloud auth configure-docker <region>-docker.pkg.dev`, and pushes several GB
|
|
32
|
+
# per engine version. Use it only if your policy forbids AR reaching the internet.
|
|
33
|
+
# A repository's mode is fixed when it is created, so changing this means a new
|
|
34
|
+
# ARTIFACT_REPO name and another setup-infrastructure run.
|
|
35
|
+
REGISTRY_MODE=proxy
|
|
36
|
+
|
|
26
37
|
REGION=europe-west1
|
|
27
38
|
|
|
28
39
|
# Optional. Unset = whichever account gcloud is logged in as. Set it when you work across
|
|
@@ -35,14 +46,16 @@ REGION=europe-west1
|
|
|
35
46
|
# Optional. Where published engine images come from.
|
|
36
47
|
# GHCR_IMAGE=ghcr.io/meffecta/agent
|
|
37
48
|
|
|
38
|
-
# Optional. The runtime shape
|
|
49
|
+
# Optional. The runtime shape asserted on every rollout, so the deployed service
|
|
39
50
|
# matches this file rather than whatever was last changed by hand. The defaults below are
|
|
40
|
-
# what setup-
|
|
51
|
+
# what `meffecta-agent setup-infra` provisions, so leaving them out is the same as setting
|
|
52
|
+
# them.
|
|
41
53
|
#
|
|
42
54
|
# SCALING picks one of two coherent pairs, and the choice is not cosmetic:
|
|
43
|
-
# scale-to-zero min-instances=0 + CPU billed per request. Costs nothing between jobs
|
|
44
|
-
#
|
|
45
|
-
#
|
|
55
|
+
# scale-to-zero min-instances=0 + CPU billed per request. Costs nothing between jobs.
|
|
56
|
+
# The service has no CPU of its own to run a timer with, so everything
|
|
57
|
+
# arrives as a request: `setup-infra` creates the queue those arrive on,
|
|
58
|
+
# and `deploy` creates the schedules that send them.
|
|
46
59
|
# always-on min-instances=1 + CPU always allocated, for a deployment that drives
|
|
47
60
|
# itself with in-process timers and has no Cloud Scheduler triggers.
|
|
48
61
|
# Running scale-to-zero without triggers is a service that does nothing; running always-on
|
package/engine.json
CHANGED
package/lib/analytics.js
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// Anonymous usage analytics, so the set-up can be improved by evidence rather than guess.
|
|
2
|
+
//
|
|
3
|
+
// This CLI runs on other people's machines, in their cloud accounts, holding their
|
|
4
|
+
// credentials. That earns a stricter standard than a web app's:
|
|
5
|
+
//
|
|
6
|
+
// - it never blocks — one bounded POST at exit, and the command's own work is done
|
|
7
|
+
// - it never throws — every path swallows its errors; analytics cannot fail a deploy
|
|
8
|
+
// - it never fires off — disabled, in CI, or with no key, capture() is a no-op
|
|
9
|
+
// - it never carries content — see WHAT IS SENT below; the list is exhaustive on purpose
|
|
10
|
+
//
|
|
11
|
+
// Dependency-free by necessity and by choice: the package has no runtime dependencies, and
|
|
12
|
+
// a tool holding someone's cloud credentials is a poor place for a supply chain. That means
|
|
13
|
+
// speaking PostHog's capture API directly rather than pulling in posthog-node.
|
|
14
|
+
//
|
|
15
|
+
// WHAT IS SENT, in full:
|
|
16
|
+
// which command ran (from the fixed list — never a word the operator typed), whether it
|
|
17
|
+
// succeeded, how long it took, this CLI's version and the engine tag it deploys, Node
|
|
18
|
+
// version, OS and architecture, how the CLI was installed, and whether a deployment.env
|
|
19
|
+
// was found. Plus a random id, minted here, so repeat runs group as one install.
|
|
20
|
+
//
|
|
21
|
+
// WHAT IS NEVER SENT:
|
|
22
|
+
// the GCP project or service name, the region, repo URLs, email or account names, file
|
|
23
|
+
// paths, command arguments, secret names or values, job names, prompts, or any error
|
|
24
|
+
// MESSAGE — only the error's class. Messages are the leak: they routinely quote a
|
|
25
|
+
// project id, a path, or a line of gcloud output.
|
|
26
|
+
//
|
|
27
|
+
// Not even a hashed project id. It would be genuinely useful — it would let one deployment
|
|
28
|
+
// be followed across machines — but a hash of a short, guessable string is pseudonymous at
|
|
29
|
+
// best, and the anonymous install id already answers most of the same questions. If that
|
|
30
|
+
// correlation is ever wanted it should be a deliberate decision, disclosed on its own, not
|
|
31
|
+
// something that arrived quietly inside a telemetry patch.
|
|
32
|
+
|
|
33
|
+
import { randomUUID } from "node:crypto";
|
|
34
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
35
|
+
import { homedir } from "node:os";
|
|
36
|
+
import { dirname, join } from "node:path";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* PostHog *project* key — write-only, designed to ship inside a client, and public by
|
|
40
|
+
* nature: this package is on npm, so treat it as published. It grants event ingestion and
|
|
41
|
+
* nothing else. Empty means nothing is ever sent.
|
|
42
|
+
*/
|
|
43
|
+
const DEFAULT_KEY = "phc_pyUMjGAJSMJtStPULeiiutucEiN6qkRhuKr5RJVuEXqX";
|
|
44
|
+
|
|
45
|
+
/** A project key belongs to one region; this one is EU cloud. */
|
|
46
|
+
const DEFAULT_HOST = "https://eu.i.posthog.com";
|
|
47
|
+
|
|
48
|
+
const LIB = "meffecta-agent-cli";
|
|
49
|
+
|
|
50
|
+
/** Analytics is never worth a hang, and never worth a visible pause. */
|
|
51
|
+
const FLUSH_TIMEOUT_MS = 1500;
|
|
52
|
+
|
|
53
|
+
/** An env var means "on" unless it is empty, "0", or "false". */
|
|
54
|
+
function truthy(value) {
|
|
55
|
+
const v = (value ?? "").trim().toLowerCase();
|
|
56
|
+
return v !== "" && v !== "0" && v !== "false";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Exported so tests can neutralise every one of them, rather than the two they remember. */
|
|
60
|
+
export const CI_VARS = [
|
|
61
|
+
"CI",
|
|
62
|
+
"CONTINUOUS_INTEGRATION",
|
|
63
|
+
"GITHUB_ACTIONS",
|
|
64
|
+
"GITLAB_CI",
|
|
65
|
+
"CIRCLECI",
|
|
66
|
+
"TRAVIS",
|
|
67
|
+
"BUILDKITE",
|
|
68
|
+
"JENKINS_URL",
|
|
69
|
+
"TEAMCITY_VERSION",
|
|
70
|
+
"BITBUCKET_BUILD_NUMBER",
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
export function isCI(env = process.env) {
|
|
74
|
+
return CI_VARS.some((key) => truthy(env[key]));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Where the choice and the anonymous id live. Machine-global, NOT beside deployment.env:
|
|
79
|
+
* the opt-out is a decision by the person at the keyboard and should cover every
|
|
80
|
+
* deployment they operate, rather than being made again per content repo — and it must
|
|
81
|
+
* never end up committed to one.
|
|
82
|
+
*/
|
|
83
|
+
export function configDir(env = process.env) {
|
|
84
|
+
const base = env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
|
|
85
|
+
return join(base, "meffecta-agent");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function configPath(env = process.env) {
|
|
89
|
+
return join(configDir(env), "config.json");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Missing or corrupt reads as empty. Never throws — this is on the startup path. */
|
|
93
|
+
export function readConfig(env = process.env) {
|
|
94
|
+
try {
|
|
95
|
+
const parsed = JSON.parse(readFileSync(configPath(env), "utf8"));
|
|
96
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
97
|
+
} catch {
|
|
98
|
+
return {};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Merge and persist atomically, so a crash mid-write cannot corrupt the choice. */
|
|
103
|
+
export function writeConfig(patch, env = process.env) {
|
|
104
|
+
const path = configPath(env);
|
|
105
|
+
const next = { ...readConfig(env), ...patch };
|
|
106
|
+
try {
|
|
107
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
108
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
109
|
+
writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`);
|
|
110
|
+
renameSync(tmp, path);
|
|
111
|
+
} catch {
|
|
112
|
+
// A read-only home directory is not a reason to fail a deploy. The consequence is a
|
|
113
|
+
// fresh id next run, which is the harmless direction to fail in.
|
|
114
|
+
}
|
|
115
|
+
return next;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* On or off, and why. Opt-out by default, with several ways to say no and one explicit
|
|
120
|
+
* way to say yes:
|
|
121
|
+
*
|
|
122
|
+
* 1. DO_NOT_TRACK=1 the cross-tool standard — always wins
|
|
123
|
+
* 2. MEFFECTA_ANALYTICS_DISABLED=1 this tool's own kill switch
|
|
124
|
+
* 3. config "disabled" `meffecta-agent analytics disable`
|
|
125
|
+
* 4. config "enabled" explicit yes — beats CI detection
|
|
126
|
+
* 5. a CI environment off: a pipeline is nobody's choice to make
|
|
127
|
+
* 6. otherwise on
|
|
128
|
+
*
|
|
129
|
+
* Pure, so every rule is testable without touching a disk or a network.
|
|
130
|
+
*/
|
|
131
|
+
export function decideAnalytics({ config = {}, env = process.env } = {}) {
|
|
132
|
+
if (truthy(env.DO_NOT_TRACK)) {
|
|
133
|
+
return { enabled: false, reason: "do-not-track" };
|
|
134
|
+
}
|
|
135
|
+
if (truthy(env.MEFFECTA_ANALYTICS_DISABLED)) {
|
|
136
|
+
return { enabled: false, reason: "env-disabled" };
|
|
137
|
+
}
|
|
138
|
+
if (config.analytics === "disabled") {
|
|
139
|
+
return { enabled: false, reason: "config-disabled" };
|
|
140
|
+
}
|
|
141
|
+
if (config.analytics === "enabled") {
|
|
142
|
+
return { enabled: true, reason: "config-enabled" };
|
|
143
|
+
}
|
|
144
|
+
if (isCI(env)) {
|
|
145
|
+
return { enabled: false, reason: "ci" };
|
|
146
|
+
}
|
|
147
|
+
return { enabled: true, reason: "default" };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Endpoint and key, with env overrides for anyone who wants to point it elsewhere. */
|
|
151
|
+
export function resolvePosthog(env = process.env) {
|
|
152
|
+
return {
|
|
153
|
+
host: (env.MEFFECTA_POSTHOG_HOST?.trim() || DEFAULT_HOST).replace(/\/+$/, ""),
|
|
154
|
+
key: env.MEFFECTA_POSTHOG_KEY?.trim() ?? DEFAULT_KEY,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The default transport: one fetch, aborted rather than allowed to hang. */
|
|
159
|
+
const defaultSender = async (url, body, timeoutMs) => {
|
|
160
|
+
const controller = new AbortController();
|
|
161
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
162
|
+
try {
|
|
163
|
+
await fetch(url, {
|
|
164
|
+
method: "POST",
|
|
165
|
+
headers: { "content-type": "application/json" },
|
|
166
|
+
body: JSON.stringify(body),
|
|
167
|
+
signal: controller.signal,
|
|
168
|
+
});
|
|
169
|
+
} finally {
|
|
170
|
+
clearTimeout(timer);
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
export class Analytics {
|
|
175
|
+
#enabled;
|
|
176
|
+
#id;
|
|
177
|
+
#key;
|
|
178
|
+
#host;
|
|
179
|
+
#version;
|
|
180
|
+
#send;
|
|
181
|
+
#queue = [];
|
|
182
|
+
|
|
183
|
+
constructor({ enabled = false, anonymousId = "", key = "", host = "", version = "", send } = {}) {
|
|
184
|
+
this.#enabled = enabled;
|
|
185
|
+
this.#id = anonymousId;
|
|
186
|
+
this.#key = key;
|
|
187
|
+
this.#host = host;
|
|
188
|
+
this.#version = version;
|
|
189
|
+
this.#send = send ?? defaultSender;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Guaranteed to do nothing — the fallback whenever anything is unclear. */
|
|
193
|
+
static off() {
|
|
194
|
+
return new Analytics();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Only true when a real, sendable pipeline exists. */
|
|
198
|
+
get active() {
|
|
199
|
+
return this.#enabled && this.#key !== "" && this.#id !== "";
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Queue an event. A no-op when off, and never throws. */
|
|
203
|
+
capture(event, properties = {}) {
|
|
204
|
+
if (!this.active) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
this.#queue.push({ event, properties, timestamp: new Date().toISOString() });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Send what is queued. Bounded, and swallows everything. */
|
|
211
|
+
async flush() {
|
|
212
|
+
if (!this.active || this.#queue.length === 0) {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const batch = this.#queue.splice(0, this.#queue.length);
|
|
216
|
+
try {
|
|
217
|
+
await this.#send(
|
|
218
|
+
`${this.#host}/batch/`,
|
|
219
|
+
{
|
|
220
|
+
api_key: this.#key,
|
|
221
|
+
batch: batch.map((e) => ({
|
|
222
|
+
event: e.event,
|
|
223
|
+
distinct_id: this.#id,
|
|
224
|
+
timestamp: e.timestamp,
|
|
225
|
+
properties: { ...e.properties, $lib: LIB, $lib_version: this.#version },
|
|
226
|
+
})),
|
|
227
|
+
},
|
|
228
|
+
FLUSH_TIMEOUT_MS,
|
|
229
|
+
);
|
|
230
|
+
} catch {
|
|
231
|
+
// Analytics must never surface an error to the operator.
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Resolve everything and hand back a client ready to use.
|
|
238
|
+
*
|
|
239
|
+
* The id is minted on the first run where analytics is on, and only then: a machine that
|
|
240
|
+
* has opted out never gets an identifier written to it at all. `firstRun` is true for the
|
|
241
|
+
* run that mints it, which is the one run that says so out loud.
|
|
242
|
+
*/
|
|
243
|
+
export function open({ env = process.env, version = "unknown", send } = {}) {
|
|
244
|
+
const config = readConfig(env);
|
|
245
|
+
const decision = decideAnalytics({ config, env });
|
|
246
|
+
if (!decision.enabled) {
|
|
247
|
+
return { analytics: Analytics.off(), decision, config, firstRun: false };
|
|
248
|
+
}
|
|
249
|
+
const { host, key } = resolvePosthog(env);
|
|
250
|
+
let anonymousId = config.anonymousId;
|
|
251
|
+
const firstRun = !anonymousId;
|
|
252
|
+
if (firstRun) {
|
|
253
|
+
anonymousId = randomUUID();
|
|
254
|
+
writeConfig({ anonymousId }, env);
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
analytics: new Analytics({ enabled: true, anonymousId, key, host, version, send }),
|
|
258
|
+
decision,
|
|
259
|
+
config: { ...config, anonymousId },
|
|
260
|
+
firstRun,
|
|
261
|
+
};
|
|
262
|
+
}
|