@meffecta/agent 1.0.3 → 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.
@@ -0,0 +1,300 @@
1
+ // What the set-up built in Google Cloud, in the terms of someone who did not build it.
2
+ //
3
+ // `status` answers "is it working", `doctor` answers "what is broken". Neither answers the
4
+ // question someone asks first, which is what all this stuff in my Google Cloud console
5
+ // even is — a dozen resources across seven products, most of them named after each other,
6
+ // none of them explaining themselves. Handing someone a `gcloud` command per product is
7
+ // not an answer either: they would have to know which products to ask about.
8
+ //
9
+ // So this walks the whole footprint in one pass, says what each thing is FOR, and links
10
+ // into the console for anyone who would rather click. Everything found that the set-up did
11
+ // not create is listed too, unexplained but visible — an inventory that quietly omitted
12
+ // what it did not recognise would be worse than none.
13
+
14
+ import { requireDeployment } from "./config.js";
15
+ import { gcloudArgs, probe, requireCommand } from "./gcloud.js";
16
+
17
+ /**
18
+ * Every Google API `setup-infra` switches on, and why.
19
+ *
20
+ * `engine` is the deployment itself and its absence breaks everything. `capability` is one
21
+ * integration — a deployment that never touches Google Ads does not need that one on, and
22
+ * saying so is the difference between a real finding and a scary red line. `ci` is only
23
+ * for a deployment that builds images in its own GCP project.
24
+ *
25
+ * Kept in step with scripts/setup-infrastructure.sh by a test, since the two drifting
26
+ * apart would make this quietly wrong in the one direction that matters.
27
+ */
28
+ export const APIS = [
29
+ { id: "run.googleapis.com", need: "engine", what: "runs the agent" },
30
+ { id: "artifactregistry.googleapis.com", need: "engine", what: "serves the engine image to it" },
31
+ { id: "secretmanager.googleapis.com", need: "engine", what: "holds its credentials" },
32
+ { id: "storage.googleapis.com", need: "engine", what: "its memory and audit buckets" },
33
+ { id: "cloudscheduler.googleapis.com", need: "engine", what: "the clock behind every cron" },
34
+ { id: "cloudtasks.googleapis.com", need: "engine", what: "webhooks, manual runs, follow-ups" },
35
+ { id: "iam.googleapis.com", need: "engine", what: "the login it acts as" },
36
+ { id: "iamcredentials.googleapis.com", need: "engine", what: "lets it mint its own access tokens" },
37
+ { id: "gmail.googleapis.com", need: "capability", what: "sending mail, reading a watched inbox" },
38
+ { id: "calendar-json.googleapis.com", need: "capability", what: "calendars" },
39
+ { id: "drive.googleapis.com", need: "capability", what: "Drive files" },
40
+ { id: "sheets.googleapis.com", need: "capability", what: "spreadsheets" },
41
+ { id: "docs.googleapis.com", need: "capability", what: "documents" },
42
+ { id: "slides.googleapis.com", need: "capability", what: "presentations" },
43
+ { id: "googleads.googleapis.com", need: "capability", what: "Google Ads" },
44
+ { id: "searchconsole.googleapis.com", need: "capability", what: "Search Console" },
45
+ { id: "analyticsdata.googleapis.com", need: "capability", what: "Google Analytics" },
46
+ ];
47
+
48
+ const CONSOLE = "https://console.cloud.google.com";
49
+
50
+ /** The bits of gcloud's naming that nobody needs to see: projects/x/locations/y/things/NAME. */
51
+ const leaf = (value) => (value ?? "").split("/").pop();
52
+
53
+ function section(title, link) {
54
+ console.log(`\n${title}`);
55
+ if (link) {
56
+ console.log(` ↗ ${link}`);
57
+ }
58
+ }
59
+
60
+ function row(mark, name, note, width) {
61
+ console.log(` ${mark} ${name.padEnd(width)} ${note}`);
62
+ }
63
+
64
+ /** Seconds → the unit a person would have said it in. */
65
+ function duration(seconds) {
66
+ const n = Number(seconds);
67
+ if (!n) {
68
+ return "";
69
+ }
70
+ const days = Math.round(n / 86400);
71
+ return days >= 365 ? `${Math.round(days / 365)} year${days >= 730 ? "s" : ""}` : `${days} days`;
72
+ }
73
+
74
+ export async function resources() {
75
+ requireCommand("gcloud", "everything here lives in your Google Cloud project");
76
+ const d = requireDeployment();
77
+ const g = (args) => probe("gcloud", gcloudArgs(d, args));
78
+ const repo = d.ARTIFACT_REPO ?? `${d.SERVICE}-images`;
79
+ const runtimeSa = `agent-runtime@${d.PROJECT}.iam.gserviceaccount.com`;
80
+
81
+ console.log(`▶ What the set-up built in Google Cloud`);
82
+ console.log(` project ${d.PROJECT} region ${d.REGION}`);
83
+ console.log(` Reading it now — a dozen questions to Google, so give it a moment.`);
84
+
85
+ // All at once: a dozen serial gcloud calls is half a minute of nothing happening, and
86
+ // none of these reads depends on another.
87
+ const [service, buckets, registry, accounts, roles, secrets, boundSecrets, schedules, queue, apis, pool] =
88
+ await Promise.all([
89
+ g(["run", "services", "describe", d.SERVICE, `--region=${d.REGION}`, "--format=json"]),
90
+ g(["storage", "buckets", "list", "--format=value[separator='\t'](name,retention_policy.retention_period)"]),
91
+ g(["artifacts", "repositories", "describe", repo, `--location=${d.REGION}`, "--format=json"]),
92
+ g(["iam", "service-accounts", "list", "--format=value[separator='\t'](email,displayName)"]),
93
+ g([
94
+ "projects",
95
+ "get-iam-policy",
96
+ d.PROJECT,
97
+ "--flatten=bindings[].members",
98
+ `--filter=bindings.members:serviceAccount:${runtimeSa}`,
99
+ "--format=value(bindings.role)",
100
+ ]),
101
+ g(["secrets", "list", "--format=value(name)"]),
102
+ g([
103
+ "run",
104
+ "services",
105
+ "describe",
106
+ d.SERVICE,
107
+ `--region=${d.REGION}`,
108
+ "--format=value(spec.template.spec.containers[0].env)",
109
+ ]),
110
+ g(["scheduler", "jobs", "list", `--location=${d.REGION}`, "--format=value(ID)"]),
111
+ g(["tasks", "queues", "describe", `${d.SERVICE}-runs`, `--location=${d.REGION}`, "--format=value(state)"]),
112
+ g(["services", "list", "--enabled", "--format=value(config.name)"]),
113
+ g(["iam", "workload-identity-pools", "describe", "github-pool", "--location=global", "--format=value(state)"]),
114
+ ]);
115
+
116
+ const missing = [];
117
+
118
+ // --- The service itself ---
119
+ section("THE AGENT", `${CONSOLE}/run/detail/${d.REGION}/${d.SERVICE}?project=${d.PROJECT}`);
120
+ if (service) {
121
+ const svc = JSON.parse(service);
122
+ const tpl = svc.spec.template;
123
+ const ann = tpl.metadata.annotations ?? {};
124
+ const sleeps = (ann["autoscaling.knative.dev/minScale"] ?? "0") === "0";
125
+ const width = d.SERVICE.length;
126
+ const tag = tpl.spec.containers[0].image.split(":").pop();
127
+ row("✔", d.SERVICE, "a Cloud Run service — this is the agent", width);
128
+ console.log(` ${svc.status?.url ?? "(no url yet)"}`);
129
+ console.log(` running engine ${tag}${tag === "latest" ? " — the newest published build" : ""}`);
130
+ console.log(
131
+ ` ${sleeps ? "asleep between jobs, so it costs nothing while idle" : "awake all the time, driving its own clock"}`,
132
+ );
133
+ } else {
134
+ missing.push("the Cloud Run service — meffecta-agent setup-infra");
135
+ row("✖", d.SERVICE, "NOT THERE — nothing is deployed yet", d.SERVICE.length);
136
+ }
137
+
138
+ // --- Buckets ---
139
+ const bucketRows = (buckets ?? "")
140
+ .split("\n")
141
+ .filter(Boolean)
142
+ .map((line) => line.split("\t"));
143
+ const known = {
144
+ [`${d.PROJECT}-memory`]: "what it remembers between runs — mounted into it as /memory",
145
+ [`${d.PROJECT}-audit`]: "an unerasable record of what it did",
146
+ };
147
+ section("WHERE IT KEEPS THINGS", `${CONSOLE}/storage/browser?project=${d.PROJECT}`);
148
+ const bucketWidth = Math.max(...Object.keys(known).map((n) => n.length), ...bucketRows.map(([n]) => leaf(n).length));
149
+ for (const name of [`${d.PROJECT}-memory`, `${d.PROJECT}-audit`]) {
150
+ const found = bucketRows.find(([n]) => leaf(n) === name);
151
+ if (found) {
152
+ const kept = duration(found[1]);
153
+ row("✔", name, `${known[name]}${kept ? `, kept ${kept}` : ""}`, bucketWidth);
154
+ } else {
155
+ missing.push(`the ${name.endsWith("-audit") ? "audit" : "memory"} bucket — meffecta-agent setup-infra`);
156
+ row("✖", name, "NOT THERE — it has nowhere to remember or record", bucketWidth);
157
+ }
158
+ }
159
+ for (const [name] of bucketRows) {
160
+ const short = leaf(name);
161
+ if (!short.startsWith(`${d.PROJECT}-memory`) && !short.startsWith(`${d.PROJECT}-audit`)) {
162
+ row("·", short, known[short] ?? "not part of the agent set-up", bucketWidth);
163
+ }
164
+ }
165
+
166
+ // --- Registry ---
167
+ section(
168
+ "WHERE THE ENGINE COMES FROM",
169
+ `${CONSOLE}/artifacts/docker/${d.PROJECT}/${d.REGION}/${repo}?project=${d.PROJECT}`,
170
+ );
171
+ if (registry) {
172
+ const ar = JSON.parse(registry);
173
+ const proxy = ar.mode === "REMOTE_REPOSITORY";
174
+ const upstream = ar.remoteRepositoryConfig?.commonRepository?.uri ?? "ghcr.io";
175
+ row(
176
+ "✔",
177
+ repo,
178
+ proxy
179
+ ? `a window onto ${upstream} — the engine is fetched on demand, never copied by you`
180
+ : "your own copy of each engine version, pushed from your machine",
181
+ repo.length,
182
+ );
183
+ console.log(
184
+ proxy
185
+ ? " Cached copies expire on their own; the real archive is public and elsewhere."
186
+ : " Old versions expire on their own — see meffecta-agent artifact-cleanup.",
187
+ );
188
+ } else {
189
+ missing.push(`the image registry ${repo} — meffecta-agent setup-infra`);
190
+ row("✖", repo, "NOT THERE — Cloud Run has nothing to pull", repo.length);
191
+ }
192
+
193
+ // --- Identities ---
194
+ const saRows = (accounts ?? "")
195
+ .split("\n")
196
+ .filter(Boolean)
197
+ .map((line) => line.split("\t"));
198
+ section("WHO IT ACTS AS", `${CONSOLE}/iam-admin/serviceaccounts?project=${d.PROJECT}`);
199
+ const saWidth = Math.max(...saRows.map(([email]) => email.split("@")[0].length), runtimeSa.split("@")[0].length);
200
+ const explain = {
201
+ "agent-runtime": "the agent's own login — every job runs as this",
202
+ "github-deployer": "used by a GitHub action to deploy, if you set one up",
203
+ };
204
+ if (!saRows.some(([email]) => email === runtimeSa)) {
205
+ missing.push("the agent-runtime service account — meffecta-agent setup-infra");
206
+ row("✖", "agent-runtime", "NOT THERE — the agent has no identity", saWidth);
207
+ }
208
+ for (const [email, display] of saRows.sort()) {
209
+ const name = email.split("@")[0];
210
+ const note = explain[name] ?? `${display || "no description"} — not part of the agent set-up`;
211
+ row(email === runtimeSa ? "✔" : explain[name] ? "✔" : "·", name, note, saWidth);
212
+ }
213
+ const held = (roles ?? "").split("\n").filter(Boolean);
214
+ if (held.length) {
215
+ console.log(` agent-runtime may, across the whole project: ${held.map((r) => leaf(r)).join(", ")}`);
216
+ console.log(` and read/write its two buckets. Nothing else.`);
217
+ }
218
+
219
+ // --- Secrets ---
220
+ const secretNames = (secrets ?? "").split("\n").filter(Boolean).map(leaf);
221
+ const boundCount = new Set((boundSecrets ?? "").match(/'([^']+)'/g) ?? []).size;
222
+ section("ITS CREDENTIALS", `${CONSOLE}/security/secret-manager?project=${d.PROJECT}`);
223
+ if (secretNames.length) {
224
+ console.log(` ✔ ${secretNames.length} secret(s) stored, encrypted, never printed by this CLI.`);
225
+ console.log(` Which ones the agent actually reads: meffecta-agent secrets`);
226
+ } else {
227
+ missing.push("every credential — meffecta-agent set-secret AGENT_API_SECRET --random");
228
+ console.log(" ✖ Nothing stored. The service cannot start without AGENT_API_SECRET.");
229
+ }
230
+ if (boundCount === 0 && secretNames.length) {
231
+ console.log(" ⚠ none of them is wired to the service — meffecta-agent secrets");
232
+ }
233
+
234
+ // --- Triggers ---
235
+ const jobs = (schedules ?? "").split("\n").filter(Boolean);
236
+ const crons = jobs.filter((name) => name.startsWith(`${d.SERVICE}-job-`));
237
+ section("WHAT SETS IT OFF", `${CONSOLE}/cloudscheduler?project=${d.PROJECT}`);
238
+ if (jobs.length) {
239
+ console.log(
240
+ ` ✔ ${crons.length} scheduled job(s)${jobs.includes(`${d.SERVICE}-sweep`) ? ", plus the housekeeping sweep" : ""}`,
241
+ );
242
+ console.log(` When each one runs: meffecta-agent triggers`);
243
+ } else {
244
+ missing.push("every trigger — meffecta-agent sync-triggers");
245
+ console.log(" ✖ Nothing scheduled. A sleeping service with no triggers runs nothing at all.");
246
+ }
247
+ console.log(
248
+ ` ${queue ? "✔" : "✖"} ${d.SERVICE}-runs — the queue that carries webhooks, manual runs and follow-ups` +
249
+ `${queue ? "" : " (NOT THERE)"}`,
250
+ );
251
+
252
+ // --- APIs ---
253
+ const enabled = new Set((apis ?? "").split("\n").filter(Boolean));
254
+ section("GOOGLE SERVICES SWITCHED ON", `${CONSOLE}/apis/dashboard?project=${d.PROJECT}`);
255
+ const apiWidth = Math.max(...APIS.map((a) => a.id.replace(".googleapis.com", "").length));
256
+ for (const group of ["engine", "capability"]) {
257
+ const mine = APIS.filter((a) => a.need === group);
258
+ const off = mine.filter((a) => !enabled.has(a.id));
259
+ console.log("");
260
+ console.log(
261
+ ` ${group === "engine" ? "Needed by the agent itself" : "Needed only for what you use"} — ${mine.length - off.length}/${mine.length} on`,
262
+ );
263
+ for (const api of mine) {
264
+ const on = enabled.has(api.id);
265
+ // A capability API that is off is a choice, not a fault: nothing breaks until a job
266
+ // reaches for it, and then it says so.
267
+ row(on ? "✔" : group === "engine" ? "✖" : "·", api.id.replace(".googleapis.com", ""), api.what, apiWidth);
268
+ }
269
+ if (off.length && group === "engine") {
270
+ missing.push(`${off.length} Google API(s) the agent needs — meffecta-agent setup-infra`);
271
+ }
272
+ }
273
+ const extra = enabled.size - APIS.filter((a) => enabled.has(a.id)).length;
274
+ if (extra > 0) {
275
+ console.log(` · and ${extra} more that Google switches on for every project — nothing to do with the agent`);
276
+ }
277
+
278
+ // --- Optional CI path ---
279
+ if (pool) {
280
+ section("DEPLOYING FROM GITHUB", `${CONSOLE}/iam-admin/workload-identity-pools?project=${d.PROJECT}`);
281
+ console.log(" ✔ github-pool — lets a GitHub action deploy without you storing a key anywhere");
282
+ }
283
+
284
+ // --- What any of it costs ---
285
+ console.log("\nWHAT COSTS ANYTHING");
286
+ console.log(" The service (only while a job is running), the stored images, the two buckets,");
287
+ console.log(" each stored secret, and each scheduled job. Idle, this is small change.");
288
+ console.log(` ↗ ${CONSOLE}/billing/linkedaccount?project=${d.PROJECT}`);
289
+
290
+ if (missing.length) {
291
+ console.log(`\n⚠ ${missing.length} thing(s) are not there yet:`);
292
+ for (const item of missing) {
293
+ console.log(` • ${item}`);
294
+ }
295
+ } else {
296
+ console.log("\n✔ Everything the set-up creates is there.");
297
+ }
298
+ console.log(" Whether it all WORKS is a different question: meffecta-agent doctor");
299
+ return 0;
300
+ }
@@ -0,0 +1,129 @@
1
+ // The credential check, in two halves — because only one of them can be a script.
2
+ //
3
+ // The engine's own script tests what the engine knows the mechanics of: its essentials,
4
+ // and the integrations that ship with it. It cannot test a system that belongs to one
5
+ // deployment. `ACME_CRM_TOKEN` means nothing to an image that ships to everyone: testing a
6
+ // credential means knowing its endpoint, its auth header, and what a good answer looks
7
+ // like, and none of that can be in a shipped file.
8
+ //
9
+ // One thing in the deployment does know: its register — `systems/`, a file per system
10
+ // saying which skill reaches it, which variables carry the credential, what access is
11
+ // allowed, and the cheapest read that proves it works. Reading that, choosing a skill,
12
+ // making the call and judging the answer is not script work — but it is exactly what the
13
+ // agent does, and this check already runs as an agent. It was simply being used to relay
14
+ // `cat` output.
15
+ //
16
+ // So the run does both: the script verbatim, then the register, system by system.
17
+ //
18
+ // The safety line is READ ONLY, and it is stated rather than implied. A register entry's
19
+ // `access:` says things like "read + send" and "read + write" — that describes what a JOB
20
+ // may do, not what a health check may do. A check that emailed someone to prove the mail
21
+ // credential works would be a check that emails someone.
22
+
23
+ import { UserError } from "./config.js";
24
+ import { parseFlags } from "./flags.js";
25
+
26
+ /** The absolute path is right: a run's cwd is the content clone, the script is in the image. */
27
+ const SCRIPT = "node /app/scripts/verify-credentials.mjs";
28
+
29
+ const PROMPT = `Check this deployment's credentials. Two parts, in order.
30
+
31
+ PART 1 — the engine's own check.
32
+
33
+ Run: ${SCRIPT}
34
+
35
+ Reproduce its output verbatim and in full, inside a fenced block. Do not summarise it,
36
+ reformat it, re-order it or correct it. It is the deterministic half and its exact wording
37
+ is the point. Add nothing of your own to it.
38
+
39
+ PART 2 — this deployment's own systems.
40
+
41
+ Part 1 tests what the ENGINE knows how to test. It cannot test a system belonging to this
42
+ deployment specifically. The register of those is \`systems/\` in your working directory —
43
+ one file per system — or, in a deployment that has not moved to that yet, a single
44
+ ENVIRONMENT.md table. Read whichever is present.
45
+
46
+ Each file's frontmatter says what to do:
47
+ system: what it is
48
+ skill: the skill that reaches it, or "none" plus how it is reached instead
49
+ requires: exact variable names, ALL of which must be set
50
+ selectors: optional per-project pointers — an unset one means that project is not
51
+ wired up, which is not a fault
52
+ access: what a JOB may do. It is NOT permission for this check.
53
+ probe: the cheapest read that proves the credential works, in plain English
54
+
55
+ For every system:
56
+
57
+ - If Part 1 already tested it, say so and move on. Never test the same thing twice.
58
+ - Otherwise carry out its \`probe:\` using the named skill. Exactly that, once. If there is
59
+ no probe:, make the smallest read-only call the skill supports — cheapest endpoint, no
60
+ pagination, no date ranges, no bulk export.
61
+ - READ ONLY, always, whatever \`access:\` says — that field describes what a job may do, not
62
+ what a health check may do. Never write, send, create, update, upload or delete. If a
63
+ system has no read path, report it untestable rather than testing it with a write.
64
+ - Never print a credential value, in whole or in part, and never put one in a command you
65
+ show. Names only.
66
+ - If a variable under \`requires:\` is unset, that is a finding, not a skip: the register is
67
+ promising a system this deployment cannot reach.
68
+
69
+ Then reconcile the other way. Part 1 ends with any credentials that are set but that it
70
+ does not know how to test. If one of those has no file in \`systems/\`, that is a finding
71
+ too — a skill only learns a system exists by reading the register, so a credential missing
72
+ from it will never be used by any job, however valid it is.
73
+
74
+ REPORT exactly like this, and nothing else:
75
+
76
+ <Part 1's output, verbatim, in a fenced block>
77
+
78
+ This deployment's own systems (systems/)
79
+ ✅ <system> <the evidence — "6 zones", "responded 200", "3 rows">
80
+ ❌ <system> <the error, one line>
81
+ ⏭️ <system> <why it could not be tested>
82
+
83
+ Register vs reality
84
+ <each mismatch on one line, or "nothing to report">
85
+
86
+ Rules for the report: under 60 lines total. This is a status report, not an
87
+ investigation — if something fails, state the failure and move on. Do not debug it, do not
88
+ retry it, do not suggest fixes unless the fix is one short clause. If a skill is missing or
89
+ a register file is unreadable, say so plainly rather than guessing at what it meant.`;
90
+
91
+ export function buildVerifyPrompt() {
92
+ return PROMPT;
93
+ }
94
+
95
+ /**
96
+ * The credential doctor. It runs in the deployment, because that is the only place the
97
+ * credentials are — and because only something with the register in front of it can test
98
+ * the systems that are this deployment's own.
99
+ *
100
+ * There was a local mode, and it was a trap: a deployment's credentials live in Secret
101
+ * Manager bound to the service, so run from a content repo it reported "0 ok, 0 failed,
102
+ * everything skipped" — zero failures having tested nothing, which reads exactly like a
103
+ * pass. The engine repo keeps `pnpm creds` for whoever has a .env; that is a maintainer's
104
+ * tool and does not belong in a client's CLI.
105
+ */
106
+ export function verifyCredentials(ask) {
107
+ return async (args) => {
108
+ const { flags, positional } = parseFlags(args, {
109
+ quick: { type: "boolean" },
110
+ model: {},
111
+ effort: {},
112
+ timeoutSeconds: { type: "int", min: 1, max: 3600 },
113
+ });
114
+ if (positional.length) {
115
+ throw new UserError(`verify-credentials takes no arguments (got "${positional.join(" ")}").`);
116
+ }
117
+ const passthrough = Object.entries(flags)
118
+ .filter(([name]) => name !== "quick")
119
+ .flatMap(([name, value]) => [`--${name}`, String(value)]);
120
+
121
+ if (flags.quick) {
122
+ console.error("Running the engine's own check on the deployment.");
123
+ return ask([...passthrough, `Run ${SCRIPT} and return its output verbatim, with nothing added.`]);
124
+ }
125
+ console.error("Checking on the deployment: the engine's own credentials, then every system in");
126
+ console.error("your register. Takes a couple of minutes — --quick does the first half only.");
127
+ return ask([...passthrough, PROMPT]);
128
+ };
129
+ }
package/lib/version.js ADDED
@@ -0,0 +1,90 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ // Version facts, in their own module because both commands.js and doctor.js need them and
6
+ // commands.js already imports doctor.js — putting them in either would make a cycle.
7
+
8
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
9
+
10
+ export function cliVersion() {
11
+ try {
12
+ return JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8")).version;
13
+ } catch {
14
+ return "unknown";
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Where this CLI was installed from, so an upgrade prints a line that actually works. The
20
+ * alternative — shelling out to `npm i -g` — guesses wrong for anyone not on npm, needs
21
+ * sudo under a root-owned prefix, and has the process replace its own package while it is
22
+ * running. Printing the right command is honest, and one keystroke away.
23
+ */
24
+ export function installMethod() {
25
+ const here = packageRoot.replaceAll("\\", "/");
26
+ if (here.includes("/_npx/")) {
27
+ return {
28
+ kind: "npx",
29
+ upgrade: "npx @meffecta/agent@latest <command>",
30
+ note: "npx fetches on demand — pin a version there rather than installing one",
31
+ };
32
+ }
33
+ if (here.includes("/pnpm/")) {
34
+ return { kind: "pnpm", upgrade: "pnpm add -g @meffecta/agent@latest" };
35
+ }
36
+ if (here.includes("/.bun/")) {
37
+ return { kind: "bun", upgrade: "bun add -g @meffecta/agent@latest" };
38
+ }
39
+ if (here.includes("/.volta/")) {
40
+ return { kind: "volta", upgrade: "volta install @meffecta/agent@latest" };
41
+ }
42
+ if (existsSync(resolve(packageRoot, "..", "src", "index.ts"))) {
43
+ return { kind: "checkout", upgrade: "git pull", note: "this is the engine checkout, not an installed package" };
44
+ }
45
+ return { kind: "npm", upgrade: "npm i -g @meffecta/agent@latest" };
46
+ }
47
+
48
+ /** The newest published version, or undefined when the registry cannot be reached. */
49
+ export async function latestPublished() {
50
+ try {
51
+ const res = await fetch("https://registry.npmjs.org/@meffecta/agent/latest", {
52
+ signal: AbortSignal.timeout(5000),
53
+ headers: { accept: "application/vnd.npm.install-v1+json, application/json" },
54
+ });
55
+ return res.ok ? ((await res.json())?.version ?? undefined) : undefined;
56
+ } catch {
57
+ return undefined;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * true when `a` is strictly newer than `b`. Deliberately strict about the shape: anything
63
+ * that is not a plain x.y.z on both sides answers false, so a prerelease or a garbled
64
+ * version never produces a confident "you are out of date".
65
+ */
66
+ export function isNewer(a, b) {
67
+ const parse = (v) => (/^\d+\.\d+\.\d+$/.test(v ?? "") ? v.split(".").map(Number) : undefined);
68
+ const [x, y] = [parse(a), parse(b)];
69
+ if (!x || !y) {
70
+ return false;
71
+ }
72
+ for (let i = 0; i < 3; i++) {
73
+ if (x[i] !== y[i]) {
74
+ return x[i] > y[i];
75
+ }
76
+ }
77
+ return false;
78
+ }
79
+
80
+ /**
81
+ * What an operator would type to reach this CLI again. The scripts print each other's
82
+ * names in their closing instructions, and the honest name depends on how you got here:
83
+ * a global install answers to `meffecta-agent`, an npx run to nothing at all once it
84
+ * exits. It is exported to every script as MEFFECTA_CLI, which is also the signal that a
85
+ * CLI exists — a deployment that lifted scripts/ out of the image has none, and there the
86
+ * scripts name each other by filename instead.
87
+ */
88
+ export function invocation() {
89
+ return installMethod().kind === "npx" ? "npx @meffecta/agent" : "meffecta-agent";
90
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meffecta/agent",
3
- "version": "1.0.3",
3
+ "version": "1.0.7",
4
4
  "description": "Set up and operate a Meffecta Agent deployment — a self-hosted Claude Code job runner.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,6 +33,7 @@
33
33
  "directory": "cli"
34
34
  },
35
35
  "scripts": {
36
- "prepack": "node prepack.mjs"
36
+ "prepack": "node prepack.mjs",
37
+ "postpack": "node prepack.mjs --clean"
37
38
  }
38
39
  }
@@ -16,6 +16,8 @@ set -euo pipefail
16
16
  # scripts/create-project.sh --name "Acme Agent" --id acme-agent
17
17
  # ORGANIZATION=123456789 scripts/create-project.sh # or FOLDER=...
18
18
 
19
+ . "$(dirname "${BASH_SOURCE[0]}")/lib/cli-names.sh"
20
+
19
21
  NAME=""
20
22
  PROJECT_ID=""
21
23
 
@@ -100,7 +102,7 @@ echo ""
100
102
  echo "✔ Created ${PROJECT_ID}"
101
103
  echo ""
102
104
  echo "Next: link a billing account (nothing else works without it) —"
103
- echo " $(dirname "$0")/link-billing.sh --project ${PROJECT_ID}"
105
+ echo " $(cmd link-billing) --project ${PROJECT_ID}"
104
106
  echo ""
105
107
  echo "And in step 3, this goes in your content repo's deployment.env:"
106
108
  echo " PROJECT=${PROJECT_ID}"
package/scripts/deploy.sh CHANGED
@@ -5,7 +5,7 @@ set -euo pipefail
5
5
  #
6
6
  # The engine's CI publishes images to GHCR and stops there: choosing when to move to a
7
7
  # new engine version is the deployment's call, not the engine repo's. This script is
8
- # that call, and it is the same script for Marcus's instance and for a client's.
8
+ # that call, and it is the same script for the vendor's own instance and for a client's.
9
9
  #
10
10
  # Cloud Run can only pull from Artifact Registry, never from GHCR directly, so an image
11
11
  # has to reach the deployment's own AR first. Two ways:
@@ -34,7 +34,7 @@ set -euo pipefail
34
34
  # bucket name or a service account breaks a deployment far worse than drift does.
35
35
  #
36
36
  # A deployment driven by Cloud Scheduler also gets its triggers re-synced afterwards
37
- # (scripts/setup-scheduler.sh): a deploy restarts the service, which is when new or renamed
37
+ # (scripts/sync-triggers.sh): a deploy restarts the service, which is when new or renamed
38
38
  # `cron:` registrations from the content repo take effect, so it is also when the Scheduler
39
39
  # jobs mirroring them have to be brought up to date. An always-on deployment drives itself
40
40
  # and is left alone.
@@ -71,14 +71,25 @@ require_gcloud
71
71
  resolve_deployment
72
72
  announce_target "Deploying ${SERVICE}"
73
73
 
74
- # --tag mirrors GHCR this deployment's Artifact Registry; --image is taken as-is
75
- # (already in AR, or reachable through an AR remote repository).
76
- if [ -z "${IMAGE}" ]; then
74
+ # --image is taken as-is. Otherwise --tag resolves against this deployment's registry, and
75
+ # how it gets there depends on REGISTRY_MODE: `proxy` names the image inside a remote
76
+ # repository and lets Artifact Registry fetch it on demand; `mirror` pulls and pushes it
77
+ # from here, which is the only path that needs docker.
78
+ if [ -z "${IMAGE}" ] && [ "${REGISTRY_MODE}" = "proxy" ]; then
79
+ # A remote repository preserves the upstream path, so ghcr.io/meffecta/agent is served at
80
+ # <repo>/meffecta/agent. Nothing is copied and nothing is pushed: the first pull populates
81
+ # AR's cache, and Cloud Run's own pull is what triggers it.
82
+ IMAGE="${REGION}-docker.pkg.dev/${PROJECT}/${ARTIFACT_REPO}/${GHCR_PATH}:${TAG}"
83
+ echo "🔗 Through the registry proxy: ${IMAGE}"
84
+ echo " (Artifact Registry fetches ${GHCR_IMAGE}:${TAG} on demand — no local docker)"
85
+ elif [ -z "${IMAGE}" ]; then
77
86
  SOURCE="${GHCR_IMAGE}:${TAG}"
78
87
  IMAGE="${REGION}-docker.pkg.dev/${PROJECT}/${ARTIFACT_REPO}/${SERVICE}:${TAG}"
79
88
  command -v docker >/dev/null || {
80
89
  echo "docker is required to mirror ${SOURCE} into Artifact Registry." >&2
81
- echo "Either install it, or set up an AR remote repository and pass --image." >&2
90
+ echo "REGISTRY_MODE=proxy in deployment.env avoids it entirely Artifact Registry" >&2
91
+ echo "fetches from ghcr.io itself. After changing it, re-run:" >&2
92
+ echo " $(cmd setup-infra)" >&2
82
93
  exit 1
83
94
  }
84
95
  echo "📥 Mirroring ${SOURCE} → ${IMAGE}"
@@ -118,7 +129,7 @@ EOF
118
129
  fi
119
130
  fi
120
131
 
121
- # scale-to-zero is the default and what setup-scheduler.sh's triggers assume: no CPU
132
+ # scale-to-zero is the default and what sync-triggers.sh's triggers assume: no CPU
122
133
  # except while a request is open, which is only correct because nothing in the service
123
134
  # fires on its own. always-on is the other coherent pair, for a deployment that drives
124
135
  # itself with in-process timers instead.
@@ -156,7 +167,7 @@ if [ "${SYNC_SCHEDULER}" = true ] &&
156
167
  gcloud run services describe "${SERVICE}" --project "${PROJECT}" --region "${REGION}" \
157
168
  --format json 2>/dev/null | grep -q AGENT_TASKS_QUEUE; then
158
169
  echo ""
159
- "$(dirname "${BASH_SOURCE[0]}")/setup-scheduler.sh" ${CONFIG_FILE:+--config "${CONFIG_FILE}"}
170
+ "$(dirname "${BASH_SOURCE[0]}")/sync-triggers.sh" ${CONFIG_FILE:+--config "${CONFIG_FILE}"}
160
171
  fi
161
172
 
162
173
  URL=$(gcloud run services describe "${SERVICE}" \