@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
package/README.md
CHANGED
|
@@ -23,10 +23,10 @@ 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
|
-
npx @meffecta/agent
|
|
29
|
+
npx @meffecta/agent sync-triggers # give it its triggers
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
Giving it access to something — Gmail, GA4, Grafana, PostHog, Cloudflare, an inbox
|
|
@@ -71,6 +71,54 @@ together, so a script can never provision the wrong shape for the engine it is a
|
|
|
71
71
|
reads it; `env` names secret-backed settings without resolving them. Setting one takes the
|
|
72
72
|
value from a pipe, a file, or a hidden prompt — never from your shell history.
|
|
73
73
|
|
|
74
|
+
## Writing a job
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
meffecta-agent create-job "a marketing report every Wednesday at 2pm"
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The model runs **on your deployment**, not on your laptop — it already holds the Claude
|
|
81
|
+
token, and more importantly its run has your content repo cloned and the engine's skills
|
|
82
|
+
mounted, so it can read your existing jobs for house style, your worlds, and
|
|
83
|
+
`systems/` for which systems this deployment can actually reach. It writes a file to
|
|
84
|
+
`jobs/<name>.md` and stops: nothing is committed, pushed or deployed, because that prompt
|
|
85
|
+
is something your agent will run and you should read it first.
|
|
86
|
+
|
|
87
|
+
What comes back is checked before it is written — a valid five-field cron in your
|
|
88
|
+
deployment's timezone, a usable job name, no `inbox:` without `allowFrom:` (the engine
|
|
89
|
+
refuses those, deliberately), an `effort:` the engine accepts, and no silent overwrite of
|
|
90
|
+
an existing job. `--dry-run` prints without writing; `--name` overrides the chosen name.
|
|
91
|
+
|
|
92
|
+
After you edit it by hand:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
meffecta-agent check-jobs # the same checks, on every job file here
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Anonymous analytics
|
|
99
|
+
|
|
100
|
+
On by default, and one command to stop:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
meffecta-agent analytics # exactly what is sent, and the current state
|
|
104
|
+
meffecta-agent analytics off # or set DO_NOT_TRACK=1
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
**Sent:** which command ran — matched against the built-in list, never a word you typed —
|
|
108
|
+
whether it worked, how long it took, the class of any error, this CLI's version and the
|
|
109
|
+
engine tag it deploys, Node version, OS and architecture, how you installed it, and whether
|
|
110
|
+
a `deployment.env` was found. Plus a random id generated on your machine, so repeat runs
|
|
111
|
+
count as one install.
|
|
112
|
+
|
|
113
|
+
**Never sent:** your GCP project or service name, region, repo URLs, account or email
|
|
114
|
+
address, file paths, command arguments, secret names or values, job names, prompts — or any
|
|
115
|
+
error *message*. Only the error's class, because messages routinely quote a project id, a
|
|
116
|
+
path, or a line of `gcloud` output.
|
|
117
|
+
|
|
118
|
+
Off automatically in CI. Nothing is written to your machine at all if you have opted out.
|
|
119
|
+
It is one bounded request after the command's own work is finished, and a failure to send
|
|
120
|
+
is silent — analytics can never delay or fail a deploy.
|
|
121
|
+
|
|
74
122
|
## Requirements
|
|
75
123
|
|
|
76
124
|
`gcloud` (authenticated), `docker` (to mirror engine images into your registry), `git`, and
|
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."],
|
|
38
|
-
["set-secret", "
|
|
39
|
+
["setup-infra", "Provision GCP, the run queue, and the Cloud Run service shell.\n --dry-run shows it first."],
|
|
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
|
+
}
|