@meffecta/agent 0.0.1

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,54 @@
1
+ # Which GCP project and service this deployment runs as.
2
+ #
3
+ # This file belongs in your CONTENT repo, committed — next to jobs/ and SYSTEM.md, not
4
+ # in the engine repo. It describes your deployment, nothing here is secret (secrets live
5
+ # in Secret Manager), and a fresh clone of the content repo is then everything an
6
+ # operator needs.
7
+ #
8
+ # Copy it there once:
9
+ # cp deployment.env.example /path/to/your-content-repo/deployment.env
10
+ #
11
+ # Then operate from the content repo, running the engine's scripts by path:
12
+ # cd /path/to/your-content-repo
13
+ # /path/to/agent/scripts/set-secret.sh AGENT_WEBHOOK_SECRET --random
14
+ # /path/to/agent/scripts/deploy.sh
15
+
16
+ # The GCP *project ID* — check it with `gcloud projects list`, as the console may have
17
+ # suffixed it and it need not match the project's display name.
18
+ PROJECT=acme-agent-123456
19
+
20
+ # The Cloud Run service.
21
+ SERVICE=acme-agent
22
+
23
+ # Docker repo in that project, holding images mirrored from GHCR.
24
+ ARTIFACT_REPO=acme-agent-images
25
+
26
+ REGION=europe-west1
27
+
28
+ # Optional. Unset = whichever account gcloud is logged in as. Set it when you work across
29
+ # several Google accounts and want this deployment pinned to one.
30
+ # ACCOUNT=you@example.com
31
+
32
+ # Optional. The service's TIMEZONE env var, which is what cron schedules are read in.
33
+ # TIMEZONE=Europe/Stockholm
34
+
35
+ # Optional. Where published engine images come from.
36
+ # GHCR_IMAGE=ghcr.io/meffecta/agent
37
+
38
+ # Optional. The runtime shape deploy.sh asserts on every rollout, so the deployed service
39
+ # matches this file rather than whatever was last changed by hand. The defaults below are
40
+ # what setup-infrastructure.sh provisions, so leaving them out is the same as setting them.
41
+ #
42
+ # 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
+ # and requires setup-scheduler.sh — the service has no CPU of its own to
45
+ # run a timer with, so everything must arrive as a request.
46
+ # always-on min-instances=1 + CPU always allocated, for a deployment that drives
47
+ # itself with in-process timers and has no Cloud Scheduler triggers.
48
+ # Running scale-to-zero without triggers is a service that does nothing; running always-on
49
+ # with them fires every cron twice.
50
+ # SCALING=scale-to-zero
51
+ # CPU=2
52
+ # MEMORY=4Gi
53
+ # MAX_INSTANCES=1 # >1 breaks the serial run queue — raise only with reason
54
+ # REQUEST_TIMEOUT=3600 # seconds one held request may last; the ceiling on a single run
package/engine.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "engineTag": "",
3
+ "builtFrom": "local"
4
+ }
@@ -0,0 +1,407 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { requireDeployment, UserError } from "./config.js";
5
+ import { api, requireCommand, stream } from "./gcloud.js";
6
+
7
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
8
+
9
+ /**
10
+ * Where the operator scripts are. In the published package they sit at its root; in a
11
+ * checkout of the engine repo they are one level up, beside cli/. Same files either way —
12
+ * the pack step copies them in, so the CLI is a front on exactly the scripts a deployment
13
+ * would otherwise run by hand.
14
+ */
15
+ export const scriptsDir = [resolve(packageRoot, "scripts"), resolve(packageRoot, "..", "scripts")].find((candidate) =>
16
+ existsSync(candidate),
17
+ );
18
+
19
+ /**
20
+ * The engine build this CLI was published alongside. CI writes it at pack time, so
21
+ * `deploy` defaults to the image from the same commit as the tooling deploying it — the
22
+ * one property that makes a separate npm package safe to have at all. Absent in a
23
+ * checkout, where "latest" is the honest answer.
24
+ */
25
+ export function engineTag() {
26
+ const file = resolve(packageRoot, "engine.json");
27
+ if (!existsSync(file)) {
28
+ return { tag: "latest", pinned: false };
29
+ }
30
+ try {
31
+ const { engineTag: tag } = JSON.parse(readFileSync(file, "utf8"));
32
+ return tag ? { tag, pinned: true } : { tag: "latest", pinned: false };
33
+ } catch {
34
+ return { tag: "latest", pinned: false };
35
+ }
36
+ }
37
+
38
+ export function cliVersion() {
39
+ try {
40
+ return JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8")).version;
41
+ } catch {
42
+ return "unknown";
43
+ }
44
+ }
45
+
46
+ /** A command that is one of the shipped scripts, with every argument passed straight on. */
47
+ function script(file, { interpreter } = {}) {
48
+ return async (args) => {
49
+ if (!scriptsDir) {
50
+ throw new UserError("The operator scripts are missing from this install — reinstall @meffecta/agent.");
51
+ }
52
+ const path = resolve(scriptsDir, file);
53
+ if (!existsSync(path)) {
54
+ throw new UserError(`Missing script: ${file}`);
55
+ }
56
+ const runner = interpreter ?? (file.endsWith(".mjs") ? "node" : "bash");
57
+ const { code, error } = await stream(runner, [path, ...args]);
58
+ if (error) {
59
+ throw new UserError(`Could not run ${runner}: ${error.message}`);
60
+ }
61
+ return code;
62
+ };
63
+ }
64
+
65
+ /** What the deployment looks like right now: revision, shape, triggers, pending work. */
66
+ async function status() {
67
+ requireCommand("gcloud", "the CLI reads your deployment through it");
68
+ const d = requireDeployment();
69
+ const { capture, gcloudArgs } = await import("./gcloud.js");
70
+ const json = capture(
71
+ "gcloud",
72
+ gcloudArgs(d, ["run", "services", "describe", d.SERVICE, `--region=${d.REGION}`, "--format=json"]),
73
+ );
74
+ const svc = JSON.parse(json);
75
+ const tpl = svc.spec.template;
76
+ const ann = tpl.metadata.annotations ?? {};
77
+ const container = tpl.spec.containers[0];
78
+ const env = Object.fromEntries(
79
+ (container.env ?? []).map((e) => [e.name, e.value ?? (e.valueFrom ? "<secret>" : "")]),
80
+ );
81
+ const external = Boolean(env.AGENT_TASKS_QUEUE && env.AGENT_PUBLIC_URL);
82
+
83
+ console.log(`▶ ${d.SERVICE} (${d.PROJECT} / ${d.REGION})`);
84
+ console.log(` url ${svc.status?.url ?? "?"}`);
85
+ console.log(` revision ${svc.status?.latestReadyRevisionName ?? "?"}`);
86
+ console.log(` image ${container.image}`);
87
+ console.log(
88
+ ` shape min ${ann["autoscaling.knative.dev/minScale"] ?? "0"}, max ${
89
+ ann["autoscaling.knative.dev/maxScale"] ?? "?"
90
+ }, cpu ${container.resources?.limits?.cpu ?? "?"}, ${container.resources?.limits?.memory ?? "?"}, ` +
91
+ `timeout ${tpl.spec.timeoutSeconds ?? "?"}s`,
92
+ );
93
+ console.log(
94
+ ` billing ${ann["run.googleapis.com/cpu-throttling"] === "false" ? "CPU always allocated" : "CPU per request"}`,
95
+ );
96
+ console.log(` triggers ${external ? "external (Cloud Scheduler + Tasks)" : "in-process timers"}`);
97
+ console.log(` content ${env.GIT_REPO_URL ?? "(unset — the service will not boot)"}`);
98
+
99
+ if (external) {
100
+ const jobs = capture(
101
+ "gcloud",
102
+ gcloudArgs(d, ["scheduler", "jobs", "list", `--location=${d.REGION}`, "--format=value(ID)"]),
103
+ )
104
+ .split("\n")
105
+ .filter(Boolean);
106
+ const crons = jobs.filter((name) => name.startsWith(`${d.SERVICE}-job-`));
107
+ const sweep = jobs.find((name) => name === `${d.SERVICE}-sweep`);
108
+ console.log(
109
+ ` scheduler ${crons.length} cron trigger(s)${sweep ? " + sweep" : " — NO SWEEP (run setup-scheduler)"}`,
110
+ );
111
+ if (crons.length === 0) {
112
+ console.log(" ⚠ scaled to zero with no triggers: this service runs nothing.");
113
+ }
114
+ }
115
+
116
+ const repo = d.ARTIFACT_REPO ?? `${d.SERVICE}-images`;
117
+ try {
118
+ const size = capture(
119
+ "gcloud",
120
+ gcloudArgs(d, ["artifacts", "repositories", "describe", repo, `--location=${d.REGION}`]),
121
+ ).match(/^Repository Size: (.+)$/m);
122
+ if (size) {
123
+ console.log(` registry ${repo} — ${size[1]}`);
124
+ }
125
+ } catch {
126
+ // A deployment pulling through a remote repository has none of its own. Not a problem.
127
+ }
128
+
129
+ const queue = JSON.parse(await api(d, "/queue"));
130
+ console.log(
131
+ ` queue ${queue.processing ? "running a job" : "idle"}, ${queue.pending?.length ?? 0} pending, ` +
132
+ `${queue.spawns?.length ?? 0} follow-up(s) scheduled`,
133
+ );
134
+ for (const spawn of queue.spawns ?? []) {
135
+ console.log(` ↳ ${spawn.job} "${spawn.label}" at ${spawn.runAt}`);
136
+ }
137
+ return 0;
138
+ }
139
+
140
+ async function jobs() {
141
+ const d = requireDeployment();
142
+ const list = JSON.parse(await api(d, "/jobs"));
143
+ if (list.length === 0) {
144
+ console.log("No jobs. The content repo's jobs/ directory is empty, or GIT_REPO_URL is wrong.");
145
+ return 0;
146
+ }
147
+ const width = Math.max(...list.map((j) => j.name.length));
148
+ for (const job of list) {
149
+ const triggers = [
150
+ job.cron && `cron ${job.cron}`,
151
+ job.webhook && `webhook /${job.webhook}`,
152
+ job.inbox && `inbox ${job.inbox}`,
153
+ ]
154
+ .filter(Boolean)
155
+ .join(", ");
156
+ console.log(`${job.name.padEnd(width)} ${triggers || "manual only"}${job.disabled ? " [disabled]" : ""}`);
157
+ }
158
+ return 0;
159
+ }
160
+
161
+ async function runJob(args) {
162
+ const name = args[0];
163
+ if (!name) {
164
+ throw new UserError("Which job? Try: meffecta-agent jobs");
165
+ }
166
+ const d = requireDeployment();
167
+ const delay = args.includes("--in") ? Number(args[args.indexOf("--in") + 1]) : 0;
168
+ const query = delay > 0 ? `?delaySeconds=${delay}` : "";
169
+ const body = JSON.parse(await api(d, `/jobs/${encodeURIComponent(name)}/run${query}`, { method: "POST" }));
170
+ console.log(
171
+ body.status === "scheduled"
172
+ ? `⏳ ${name} scheduled in ${delay}s`
173
+ : `▶ ${name} queued — it runs on the service, not here. Watch it with: meffecta-agent status`,
174
+ );
175
+ return 0;
176
+ }
177
+
178
+ async function ask(args) {
179
+ const prompt = args.filter((a) => !a.startsWith("--")).join(" ");
180
+ if (!prompt) {
181
+ throw new UserError('Ask it what? e.g. meffecta-agent ask "how did search do last week"');
182
+ }
183
+ const d = requireDeployment();
184
+ const flag = (name) => (args.includes(name) ? args[args.indexOf(name) + 1] : undefined);
185
+ const params = new URLSearchParams({ prompt });
186
+ for (const name of ["model", "effort", "timeoutSeconds"]) {
187
+ const value = flag(`--${name}`);
188
+ if (value) {
189
+ params.set(name, value);
190
+ }
191
+ }
192
+ console.error("Running on the deployment — this takes as long as the run does.");
193
+ process.stdout.write(await api(d, `/test?${params}`, { accept: "text/markdown" }));
194
+ process.stdout.write("\n");
195
+ return 0;
196
+ }
197
+
198
+ async function sweep() {
199
+ requireCommand("gcloud", "the sweep is a Cloud Scheduler job");
200
+ const d = requireDeployment();
201
+ const { capture, gcloudArgs } = await import("./gcloud.js");
202
+ capture("gcloud", gcloudArgs(d, ["scheduler", "jobs", "run", `${d.SERVICE}-sweep`, `--location=${d.REGION}`]));
203
+ console.log("▶ Sweep triggered: due follow-ups, journalled runs, and the watched inboxes.");
204
+ return 0;
205
+ }
206
+
207
+ /** The service's environment: plain values shown, secret-backed ones named but never read. */
208
+ async function envList() {
209
+ requireCommand("gcloud", "the CLI reads your deployment through it");
210
+ const d = requireDeployment();
211
+ const { capture, gcloudArgs } = await import("./gcloud.js");
212
+ const svc = JSON.parse(
213
+ capture(
214
+ "gcloud",
215
+ gcloudArgs(d, ["run", "services", "describe", d.SERVICE, `--region=${d.REGION}`, "--format=json"]),
216
+ ),
217
+ );
218
+ const entries = svc.spec.template.spec.containers[0].env ?? [];
219
+ if (entries.length === 0) {
220
+ console.log("Nothing set. At minimum this service needs GIT_REPO_URL and AGENT_WEBHOOK_SECRET.");
221
+ return 0;
222
+ }
223
+ const width = Math.max(...entries.map((e) => e.name.length));
224
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
225
+ const ref = entry.valueFrom?.secretKeyRef;
226
+ // A secret's value is deliberately not fetched: listing configuration should never be
227
+ // a way to read credentials out of a deployment.
228
+ console.log(
229
+ `${entry.name.padEnd(width)} ${ref ? `← secret ${ref.name}:${ref.key ?? "latest"}` : (entry.value ?? "")}`,
230
+ );
231
+ }
232
+ console.log(`\n${entries.length} set. Change one: meffecta-agent set-env NAME=VALUE`);
233
+ return 0;
234
+ }
235
+
236
+ /** What is in Secret Manager, and whether the service actually reads it. */
237
+ async function secretsList() {
238
+ requireCommand("gcloud", "the CLI reads your deployment through it");
239
+ const d = requireDeployment();
240
+ const { capture, gcloudArgs } = await import("./gcloud.js");
241
+ const secrets = capture("gcloud", gcloudArgs(d, ["secrets", "list", "--format=value(name)"]))
242
+ .split("\n")
243
+ .filter(Boolean)
244
+ .map((name) => name.split("/").pop());
245
+ const svc = JSON.parse(
246
+ capture(
247
+ "gcloud",
248
+ gcloudArgs(d, ["run", "services", "describe", d.SERVICE, `--region=${d.REGION}`, "--format=json"]),
249
+ ),
250
+ );
251
+ const bound = new Set(
252
+ (svc.spec.template.spec.containers[0].env ?? []).map((e) => e.valueFrom?.secretKeyRef?.name).filter(Boolean),
253
+ );
254
+ if (secrets.length === 0) {
255
+ console.log("No secrets in this project yet. Start with:");
256
+ console.log(" meffecta-agent set-secret AGENT_WEBHOOK_SECRET --random");
257
+ return 0;
258
+ }
259
+ const width = Math.max(...secrets.map((name) => name.length));
260
+ for (const name of secrets.sort()) {
261
+ console.log(`${name.padEnd(width)} ${bound.has(name) ? "bound to the service" : "stored, NOT bound"}`);
262
+ }
263
+ const orphans = [...bound].filter((name) => !secrets.includes(name));
264
+ for (const name of orphans) {
265
+ console.log(`${name.padEnd(width)} ⚠ referenced by the service but missing from Secret Manager`);
266
+ }
267
+ console.log(`\nValues are never printed. Set or rotate one: meffecta-agent set-secret NAME [--random]`);
268
+ return 0;
269
+ }
270
+
271
+ /** The things that drive a scaled-to-zero service: Cloud Scheduler jobs and the task queue. */
272
+ async function triggers() {
273
+ requireCommand("gcloud", "the triggers live in Cloud Scheduler and Cloud Tasks");
274
+ const d = requireDeployment();
275
+ const { capture, gcloudArgs } = await import("./gcloud.js");
276
+ const rows = capture(
277
+ "gcloud",
278
+ gcloudArgs(d, [
279
+ "scheduler",
280
+ "jobs",
281
+ "list",
282
+ `--location=${d.REGION}`,
283
+ "--format=value[separator='\t'](ID,schedule,state,lastAttemptTime)",
284
+ ]),
285
+ )
286
+ .split("\n")
287
+ .filter(Boolean)
288
+ .map((line) => line.split("\t"))
289
+ .filter(([id]) => id.startsWith(`${d.SERVICE}-`));
290
+
291
+ if (rows.length === 0) {
292
+ console.log("No triggers. If this service is scaled to zero it is running nothing:");
293
+ console.log(" meffecta-agent setup-scheduler");
294
+ return 0;
295
+ }
296
+ const width = Math.max(...rows.map(([id]) => id.length));
297
+ for (const [id, schedule, state, last] of rows.sort()) {
298
+ const flag = state === "ENABLED" ? " " : "⏸";
299
+ console.log(`${flag} ${id.padEnd(width)} ${(schedule ?? "").padEnd(14)} last ${last || "never"}`);
300
+ }
301
+
302
+ try {
303
+ const queue = capture(
304
+ "gcloud",
305
+ gcloudArgs(d, [
306
+ "tasks",
307
+ "queues",
308
+ "describe",
309
+ `${d.SERVICE}-runs`,
310
+ `--location=${d.REGION}`,
311
+ "--format=value[separator='\t'](state,stats.tasksCount)",
312
+ ]),
313
+ ).split("\t");
314
+ console.log(`\nTask queue ${d.SERVICE}-runs: ${queue[0] ?? "?"}, ${queue[1] || 0} task(s) waiting`);
315
+ } catch {
316
+ console.log(`\nTask queue ${d.SERVICE}-runs: not created — run setup-scheduler`);
317
+ }
318
+ return 0;
319
+ }
320
+
321
+ /** Recent service logs, without making anyone remember the filter syntax. */
322
+ async function logs(args) {
323
+ requireCommand("gcloud", "logs come from Cloud Logging");
324
+ const d = requireDeployment();
325
+ const limit = args.includes("--limit") ? args[args.indexOf("--limit") + 1] : "40";
326
+ const { code } = await stream("gcloud", [
327
+ "logging",
328
+ "read",
329
+ `resource.type="cloud_run_revision" AND resource.labels.service_name="${d.SERVICE}"`,
330
+ `--project=${d.PROJECT}`,
331
+ `--limit=${limit}`,
332
+ "--format=value[separator=' '](timestamp,severity,textPayload,jsonPayload.msg)",
333
+ ]);
334
+ return code;
335
+ }
336
+
337
+ /** Start a deployment.env in the current directory, from the template in this package. */
338
+ async function init(args) {
339
+ const { copyFileSync, existsSync: exists } = await import("node:fs");
340
+ const target = resolve(process.cwd(), "deployment.env");
341
+ if (exists(target) && !args.includes("--force")) {
342
+ console.log(`deployment.env already exists here. Leaving it alone (--force to replace).`);
343
+ return 0;
344
+ }
345
+ const template = [
346
+ resolve(packageRoot, "deployment.env.example"),
347
+ resolve(packageRoot, "..", "deployment.env.example"),
348
+ ].find((candidate) => existsSync(candidate));
349
+ if (!template) {
350
+ throw new UserError("The deployment.env template is missing from this install — reinstall @meffecta/agent.");
351
+ }
352
+ copyFileSync(template, target);
353
+ console.log("✅ Wrote deployment.env here.\n");
354
+ console.log("Fill in PROJECT and SERVICE — those two are never guessed — then commit it.");
355
+ console.log("It belongs in your content repo, beside jobs/, because which GCP project a");
356
+ console.log("set of jobs runs as is a fact about the deployment and none of it is secret.\n");
357
+ console.log("Next: meffecta-agent steps");
358
+ return 0;
359
+ }
360
+
361
+ export const GROUPS = [
362
+ {
363
+ title: "Set up a deployment, in order",
364
+ commands: [
365
+ ["init", "Start a deployment.env here, from the template", init],
366
+ ["create-project", "Create the GCP project this deployment lives in", script("create-project.sh")],
367
+ ["link-billing", "Attach a billing account to it", script("link-billing.sh")],
368
+ [
369
+ "setup-infra",
370
+ "Provision GCP: registry, buckets, service account, service shell",
371
+ script("setup-infrastructure.sh"),
372
+ ],
373
+ ["mint-gmail", "Mint a Gmail/Calendar refresh token for one account", script("mint-gmail-token.mjs")],
374
+ ["mint-graph", "Mint a Microsoft Graph refresh token for one mailbox", script("mint-graph-token.mjs")],
375
+ ["set-secret", "Store a secret and bind it to the service (--random to generate)", script("set-secret.sh")],
376
+ ["set-env", "Set non-secret service settings (NAME=VALUE …)", script("set-env.sh")],
377
+ ["deploy", "Roll out an engine image and assert the runtime shape", script("deploy.sh")],
378
+ ["setup-scheduler", "Create the external triggers — Cloud Scheduler + Cloud Tasks", script("setup-scheduler.sh")],
379
+ ["artifact-cleanup", "Expire mirrored images in the deployment's registry", script("set-artifact-cleanup.sh")],
380
+ ],
381
+ },
382
+ {
383
+ title: "Look at what is there",
384
+ commands: [
385
+ ["status", "What the deployment looks like right now", status],
386
+ ["jobs", "The jobs it has registered, and what triggers them", jobs],
387
+ ["env", "Every setting on the service (secret values never printed)", envList],
388
+ ["secrets", "What is in Secret Manager, and whether the service reads it", secretsList],
389
+ ["triggers", "The Cloud Scheduler jobs and task queue that drive it", triggers],
390
+ ["logs", "Recent service logs (--limit N)", logs],
391
+ ],
392
+ },
393
+ {
394
+ title: "Operate it",
395
+ commands: [
396
+ ["run", "Trigger one job now, or --in <seconds>", runJob],
397
+ ["ask", "Ask it something as a one-off run", ask],
398
+ ["sweep", "Run the housekeeping sweep now", sweep],
399
+ ["verify-credentials", "Exercise every configured credential from here", script("verify-credentials.mjs")],
400
+ ["update-tooling", "Refresh the shipped scripts from an engine image (docker path)", script("update-tooling.sh")],
401
+ ],
402
+ },
403
+ ];
404
+
405
+ export const COMMANDS = new Map(
406
+ GROUPS.flatMap((group) => group.commands.map(([name, summary, handler]) => [name, { name, summary, handler }])),
407
+ );
package/lib/config.js ADDED
@@ -0,0 +1,56 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+
4
+ /**
5
+ * The deployment's settings, read the same way the shell scripts read them: plain
6
+ * KEY=value lines in ./deployment.env, in the content repo you are standing in. An
7
+ * environment variable already set wins, which is the one-off override.
8
+ *
9
+ * Kept deliberately in step with scripts/lib/deployment.sh — the CLI is a front on those
10
+ * scripts, so the two must agree about where a deployment's identity comes from.
11
+ */
12
+ export function loadDeployment(cwd = process.cwd(), configPath) {
13
+ const file = configPath ?? process.env.DEPLOYMENT_CONFIG ?? resolve(cwd, "deployment.env");
14
+ const values = {};
15
+ if (existsSync(file)) {
16
+ for (const raw of readFileSync(file, "utf8").split("\n")) {
17
+ const line = raw.trim();
18
+ if (!line || line.startsWith("#")) {
19
+ continue;
20
+ }
21
+ const eq = line.indexOf("=");
22
+ if (eq < 1) {
23
+ continue;
24
+ }
25
+ const key = line.slice(0, eq).trim();
26
+ let value = line.slice(eq + 1).trim();
27
+ value = value.replace(/\s+#.*$/, "").trim();
28
+ value = value.replace(/^(["'])(.*)\1$/, "$2");
29
+ values[key] = value;
30
+ }
31
+ values.__file = file;
32
+ }
33
+ for (const key of ["PROJECT", "SERVICE", "REGION", "ACCOUNT", "ARTIFACT_REPO"]) {
34
+ if (process.env[key]) {
35
+ values[key] = process.env[key];
36
+ }
37
+ }
38
+ values.REGION ??= "europe-west1";
39
+ return values;
40
+ }
41
+
42
+ export function requireDeployment(cwd) {
43
+ const d = loadDeployment(cwd);
44
+ if (!d.PROJECT || !d.SERVICE) {
45
+ throw new UserError(
46
+ `No deployment.env found in ${cwd ?? process.cwd()}.\n\n` +
47
+ "That file names the GCP project and Cloud Run service this deployment is, and it\n" +
48
+ "lives in your content repo beside jobs/ — so either run this from there, or start\n" +
49
+ "one here:\n\n meffecta-agent init",
50
+ );
51
+ }
52
+ return d;
53
+ }
54
+
55
+ /** An error whose message is the whole point — printed plainly, no stack. */
56
+ export class UserError extends Error {}
package/lib/gcloud.js ADDED
@@ -0,0 +1,109 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { UserError } from "./config.js";
3
+
4
+ /** Run a command with its output going straight to the terminal; resolve with its code. */
5
+ export function stream(command, args, options = {}) {
6
+ return new Promise((resolveRun) => {
7
+ const child = spawn(command, args, { stdio: "inherit", ...options });
8
+ child.on("error", (err) => {
9
+ resolveRun({ code: 127, error: err });
10
+ });
11
+ child.on("close", (code) => resolveRun({ code: code ?? 0 }));
12
+ });
13
+ }
14
+
15
+ /**
16
+ * Run a command and capture stdout. Used for the reads the CLI formats itself, and for
17
+ * the one value that must never reach the terminal — the webhook secret.
18
+ */
19
+ export function capture(command, args) {
20
+ const result = spawnSync(command, args, { encoding: "utf8" });
21
+ if (result.error) {
22
+ throw new UserError(`Could not run ${command}: ${result.error.message}`);
23
+ }
24
+ if (result.status !== 0) {
25
+ const output = (result.stderr || result.stdout || "").trim();
26
+ // The most common failure by far, and gcloud's own wording buries the fix in a stack.
27
+ if (/[Rr]eauthentication failed|auth login|credentials.*not found|not currently active/.test(output)) {
28
+ throw new UserError("Your gcloud session has expired.\n\n gcloud auth login\n\nThen run this again.");
29
+ }
30
+ if (/PERMISSION_DENIED|does not have permission|Permission.*denied/.test(output)) {
31
+ throw new UserError(
32
+ `Your account is not allowed to do that in this project.\n\n${output.split("\n").slice(0, 4).join("\n")}`,
33
+ );
34
+ }
35
+ const detail = output.split("\n").slice(0, 6).join("\n");
36
+ throw new UserError(`${command} ${args[0] ?? ""} failed:\n${detail}`);
37
+ }
38
+ return result.stdout.trim();
39
+ }
40
+
41
+ export function requireCommand(name, why) {
42
+ const found = spawnSync(process.platform === "win32" ? "where" : "which", [name], { encoding: "utf8" });
43
+ if (found.status !== 0) {
44
+ throw new UserError(`${name} is required — ${why}`);
45
+ }
46
+ }
47
+
48
+ /** The gcloud prefix every call shares: the deployment's project, and its account if pinned. */
49
+ export function gcloudArgs(deployment, args) {
50
+ const prefix = [`--project=${deployment.PROJECT}`];
51
+ if (deployment.ACCOUNT) {
52
+ prefix.push(`--account=${deployment.ACCOUNT}`);
53
+ }
54
+ return [...args, ...prefix];
55
+ }
56
+
57
+ export function serviceUrl(deployment) {
58
+ return capture(
59
+ "gcloud",
60
+ gcloudArgs(deployment, [
61
+ "run",
62
+ "services",
63
+ "describe",
64
+ deployment.SERVICE,
65
+ `--region=${deployment.REGION}`,
66
+ "--format=value(status.url)",
67
+ ]),
68
+ );
69
+ }
70
+
71
+ /**
72
+ * The deployment's webhook secret, straight from Secret Manager into memory. It is never
73
+ * printed, never passed as a process argument, and only ever leaves here as a header.
74
+ */
75
+ export function webhookSecret(deployment) {
76
+ try {
77
+ return capture(
78
+ "gcloud",
79
+ gcloudArgs(deployment, ["secrets", "versions", "access", "latest", "--secret=AGENT_WEBHOOK_SECRET"]),
80
+ );
81
+ } catch {
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
+ );
86
+ }
87
+ }
88
+
89
+ /** An authenticated request to the deployment's own API. */
90
+ export async function api(deployment, path, { method = "GET", body, accept } = {}) {
91
+ const url = `${serviceUrl(deployment)}${path}`;
92
+ const headers = { Authorization: `Bearer ${webhookSecret(deployment)}` };
93
+ if (accept) {
94
+ headers.Accept = accept;
95
+ }
96
+ if (body !== undefined) {
97
+ headers["Content-Type"] = "application/json";
98
+ }
99
+ const res = await fetch(url, {
100
+ method,
101
+ headers,
102
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
103
+ });
104
+ const text = await res.text();
105
+ if (!res.ok) {
106
+ throw new UserError(`${method} ${path} → ${res.status}\n${text.slice(0, 400)}`);
107
+ }
108
+ return text;
109
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@meffecta/agent",
3
+ "version": "0.0.1",
4
+ "description": "Set up and operate a Meffecta Agent deployment \u2014 a self-hosted Claude Code job runner.",
5
+ "type": "module",
6
+ "bin": {
7
+ "meffecta-agent": "bin/meffecta-agent.js"
8
+ },
9
+ "engines": {
10
+ "node": ">=20"
11
+ },
12
+ "files": [
13
+ "bin",
14
+ "lib",
15
+ "scripts",
16
+ "engine.json",
17
+ "IMPLEMENTATION.md",
18
+ "ARCHITECTURE.md",
19
+ "deployment.env.example",
20
+ "README.md"
21
+ ],
22
+ "keywords": [
23
+ "meffecta",
24
+ "agent",
25
+ "claude",
26
+ "cloud-run",
27
+ "automation"
28
+ ],
29
+ "license": "UNLICENSED",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/meffecta/agent.git",
33
+ "directory": "cli"
34
+ },
35
+ "scripts": {
36
+ "prepack": "node prepack.mjs"
37
+ }
38
+ }