@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/lib/commands.js CHANGED
@@ -1,23 +1,41 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { dirname, resolve } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
+ import { configPath, decideAnalytics, readConfig, resolvePosthog, writeConfig } from "./analytics.js";
4
5
  import { requireDeployment, UserError } from "./config.js";
6
+ import { checkJobs, createJob } from "./create-job.js";
5
7
  import { runDoctor } from "./doctor.js";
6
8
  import { parseFlags } from "./flags.js";
7
9
  import { api, requireCommand, stream } from "./gcloud.js";
8
10
  import { connect } from "./integrations.js";
11
+ import { resources } from "./resources.js";
12
+ import { verifyCredentials } from "./verify-credentials.js";
13
+ import { cliVersion, installMethod, invocation, isNewer, latestPublished } from "./version.js";
9
14
 
10
15
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
11
16
 
17
+ // Re-exported so the entrypoint keeps one import site for everything it prints.
18
+ export { cliVersion };
19
+
12
20
  /**
13
21
  * Where the operator scripts are. In the published package they sit at its root; in a
14
22
  * checkout of the engine repo they are one level up, beside cli/. Same files either way —
15
23
  * the pack step copies them in, so the CLI is a front on exactly the scripts a deployment
16
24
  * would otherwise run by hand.
25
+ *
26
+ * **A checkout wins outright**, even when a vendored copy is present. `prepack` leaves one
27
+ * in cli/scripts, it is gitignored so it never shows up in `git status`, and a linked CLI
28
+ * reading that frozen snapshot is how you edit a script, run the command, and watch it
29
+ * behave exactly as though you had not. Same failure as a stale engine.json pinning a tag
30
+ * that was never published, and it gets the same guard: the engine's source sitting
31
+ * alongside means this is the repo, not an install of it.
17
32
  */
18
- export const scriptsDir = [resolve(packageRoot, "scripts"), resolve(packageRoot, "..", "scripts")].find((candidate) =>
19
- existsSync(candidate),
20
- );
33
+ const inCheckout = existsSync(resolve(packageRoot, "..", "src", "index.ts"));
34
+ export const scriptsDir = [
35
+ ...(inCheckout ? [resolve(packageRoot, "..", "scripts")] : []),
36
+ resolve(packageRoot, "scripts"),
37
+ resolve(packageRoot, "..", "scripts"),
38
+ ].find((candidate) => existsSync(candidate));
21
39
 
22
40
  /**
23
41
  * The engine build this CLI was published alongside. CI writes it at pack time, so
@@ -45,14 +63,6 @@ export function engineTag() {
45
63
  }
46
64
  }
47
65
 
48
- export function cliVersion() {
49
- try {
50
- return JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8")).version;
51
- } catch {
52
- return "unknown";
53
- }
54
- }
55
-
56
66
  /** A command that is one of the shipped scripts, with every argument passed straight on. */
57
67
  function script(file, { interpreter } = {}) {
58
68
  return async (args) => {
@@ -64,7 +74,12 @@ function script(file, { interpreter } = {}) {
64
74
  throw new UserError(`Missing script: ${file}`);
65
75
  }
66
76
  const runner = interpreter ?? (file.endsWith(".mjs") ? "node" : "bash");
67
- const { code, error } = await stream(runner, [path, ...args]);
77
+ // MEFFECTA_CLI is how a script knows it was reached through the CLI, and what to call
78
+ // the next step: `meffecta-agent deploy`, never ./scripts/deploy.sh. A deployment run
79
+ // by someone outside Meffecta cannot clone this repo and has never seen those files.
80
+ const { code, error } = await stream(runner, [path, ...args], {
81
+ env: { ...process.env, MEFFECTA_CLI: invocation() },
82
+ });
68
83
  if (error) {
69
84
  throw new UserError(`Could not run ${runner}: ${error.message}`);
70
85
  }
@@ -72,6 +87,87 @@ function script(file, { interpreter } = {}) {
72
87
  };
73
88
  }
74
89
 
90
+ /** The engine tag the deployment is actually running, from the image on its live revision. */
91
+ async function runningEngineTag() {
92
+ try {
93
+ const d = requireDeployment();
94
+ const { capture, gcloudArgs } = await import("./gcloud.js");
95
+ const json = capture(
96
+ "gcloud",
97
+ gcloudArgs(d, [
98
+ "run",
99
+ "services",
100
+ "describe",
101
+ d.SERVICE,
102
+ `--region=${d.REGION}`,
103
+ "--format=value(spec.template.spec.containers[0].image)",
104
+ ]),
105
+ );
106
+ return json.trim().split(":").pop() || undefined;
107
+ } catch {
108
+ // No deployment.env, no gcloud, no service yet — all fine, this line is just omitted.
109
+ return undefined;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Am I current? Three numbers, because being "up to date" here has three parts and the
115
+ * dangerous state is between them: new tooling pins a NEW engine tag, but the deployment
116
+ * keeps running the old image until `deploy`. Tooling that provisions a shape the running
117
+ * engine does not implement is exactly what releasing the pair together prevents — and it
118
+ * reopens here, on the operator's machine, at upgrade time.
119
+ */
120
+ async function upgrade(args) {
121
+ const checkOnly = args.includes("--check");
122
+ const installed = cliVersion();
123
+ const latest = await latestPublished();
124
+ const running = await runningEngineTag();
125
+ const how = installMethod();
126
+
127
+ console.log(`▶ @meffecta/agent`);
128
+ console.log(` installed ${installed}${how.kind === "checkout" ? " (a checkout, not an installed package)" : ""}`);
129
+ console.log(` published ${latest ?? "unknown — could not reach the npm registry"}`);
130
+ if (running) {
131
+ console.log(` engine ${running} (what your deployment is running now)`);
132
+ }
133
+ console.log("");
134
+
135
+ const behind = latest && isNewer(latest, installed);
136
+ if (!latest) {
137
+ console.log("Could not check for a newer version. Being offline is not a problem — this");
138
+ console.log("command only reads the registry; nothing else needs it.");
139
+ return 0;
140
+ }
141
+ if (!behind) {
142
+ console.log(`Up to date.`);
143
+ } else {
144
+ console.log(`A newer version is out: ${installed} → ${latest}`);
145
+ if (how.note) {
146
+ console.log(` ${how.note}`);
147
+ }
148
+ console.log("");
149
+ console.log(` ${how.upgrade}`);
150
+ if (how.kind !== "checkout") {
151
+ console.log(` meffecta-agent deploy`);
152
+ console.log("");
153
+ console.log("Both lines. The first gets tooling that pins a newer engine; the second is");
154
+ console.log("what actually moves your deployment onto it. Stopping after the first leaves");
155
+ console.log("tooling that provisions a shape the running engine may not implement.");
156
+ }
157
+ }
158
+
159
+ // A deployment can be behind the CLI without the CLI being behind npm — someone upgraded
160
+ // the tooling and never deployed. That is the half-upgraded state, and it is worth saying
161
+ // out loud rather than leaving to be noticed.
162
+ const { tag, pinned } = engineTag();
163
+ if (pinned && running && running !== tag && !checkOnly) {
164
+ console.log("");
165
+ console.log(`⚠ This CLI deploys engine ${tag}, but the deployment is running ${running}.`);
166
+ console.log(` meffecta-agent deploy (rolls it onto ${tag} and re-syncs the triggers)`);
167
+ }
168
+ return 0;
169
+ }
170
+
75
171
  /** What the deployment looks like right now: revision, shape, triggers, pending work. */
76
172
  async function status() {
77
173
  requireCommand("gcloud", "the CLI reads your deployment through it");
@@ -116,7 +212,7 @@ async function status() {
116
212
  const crons = jobs.filter((name) => name.startsWith(`${d.SERVICE}-job-`));
117
213
  const sweep = jobs.find((name) => name === `${d.SERVICE}-sweep`);
118
214
  console.log(
119
- ` scheduler ${crons.length} cron trigger(s)${sweep ? " + sweep" : " — NO SWEEP (run setup-scheduler)"}`,
215
+ ` scheduler ${crons.length} cron trigger(s)${sweep ? " + sweep" : " — NO SWEEP (run sync-triggers)"}`,
120
216
  );
121
217
  if (crons.length === 0) {
122
218
  console.log(" ⚠ scaled to zero with no triggers: this service runs nothing.");
@@ -223,6 +319,67 @@ async function ask(args) {
223
319
  return 0;
224
320
  }
225
321
 
322
+ /**
323
+ * `analytics` — the transparency surface, and the switch.
324
+ *
325
+ * Nothing is collected that could identify a deployment, but "trust me" is not a policy.
326
+ * This prints the exhaustive list of what is and is not sent, where the choice is stored,
327
+ * the state and the reason for it, and the one command that turns it off.
328
+ */
329
+ const ANALYTICS_REASONS = {
330
+ "do-not-track": "the DO_NOT_TRACK environment variable is set",
331
+ "env-disabled": "MEFFECTA_ANALYTICS_DISABLED is set",
332
+ "config-disabled": "you ran `meffecta-agent analytics off`",
333
+ "config-enabled": "you ran `meffecta-agent analytics on`",
334
+ ci: "this looks like CI, where it is off by default",
335
+ default: "the default (on, and one command to turn off)",
336
+ };
337
+
338
+ async function analytics(args) {
339
+ const [action] = args;
340
+ if (action === "on" || action === "enable") {
341
+ writeConfig({ analytics: "enabled" });
342
+ console.log("✔ Anonymous analytics on. It steers what gets fixed and documented next.");
343
+ console.log(` Recorded in ${configPath()}`);
344
+ return 0;
345
+ }
346
+ if (action === "off" || action === "disable") {
347
+ writeConfig({ analytics: "disabled" });
348
+ console.log("✔ Anonymous analytics off. Nothing further is sent from this machine.");
349
+ console.log(` Recorded in ${configPath()}`);
350
+ return 0;
351
+ }
352
+ if (action && action !== "status") {
353
+ throw new UserError(`Unknown: analytics ${action}. Try: analytics status | on | off`);
354
+ }
355
+
356
+ const config = readConfig();
357
+ const decision = decideAnalytics({ config });
358
+ const { host, key } = resolvePosthog();
359
+ console.log(`Analytics: ${decision.enabled ? "ON" : "OFF"} — ${ANALYTICS_REASONS[decision.reason]}.`);
360
+ console.log(` choice stored in ${configPath()}`);
361
+ console.log(` anonymous id ${config.anonymousId ?? "(none yet — assigned on the first run with it on)"}`);
362
+ if (decision.enabled) {
363
+ console.log(` sent to ${key ? host : "nowhere — no key is configured in this build"}`);
364
+ }
365
+ console.log("");
366
+ console.log("Sent, and nothing else:");
367
+ console.log(" • which command you ran, from the fixed list — never a word you typed");
368
+ console.log(" • whether it worked, how long it took, and the class of any error");
369
+ console.log(" • this CLI's version, the engine tag it deploys, Node, OS, architecture");
370
+ console.log(" • how you installed it, and whether a deployment.env was found");
371
+ console.log(" • a random id, made on this machine, so repeat runs count as one install");
372
+ console.log("");
373
+ console.log("Never sent: your GCP project or service name, region, repo URLs, account or");
374
+ console.log("email, file paths, arguments, secret names or values, job names, prompts —");
375
+ console.log("or any error message. Only the error's class, because messages quote paths");
376
+ console.log("and project ids.");
377
+ console.log("");
378
+ console.log("Turn it off: meffecta-agent analytics off (or set DO_NOT_TRACK=1)");
379
+ console.log("Turn it on: meffecta-agent analytics on");
380
+ return 0;
381
+ }
382
+
226
383
  async function sweep() {
227
384
  requireCommand("gcloud", "the sweep is a Cloud Scheduler job");
228
385
  const d = requireDeployment();
@@ -245,7 +402,7 @@ async function envList() {
245
402
  );
246
403
  const entries = svc.spec.template.spec.containers[0].env ?? [];
247
404
  if (entries.length === 0) {
248
- console.log("Nothing set. At minimum this service needs GIT_REPO_URL and AGENT_WEBHOOK_SECRET.");
405
+ console.log("Nothing set. At minimum this service needs GIT_REPO_URL and AGENT_API_SECRET.");
249
406
  return 0;
250
407
  }
251
408
  const width = Math.max(...entries.map((e) => e.name.length));
@@ -281,7 +438,7 @@ async function secretsList() {
281
438
  );
282
439
  if (secrets.length === 0) {
283
440
  console.log("No secrets in this project yet. Start with:");
284
- console.log(" meffecta-agent set-secret AGENT_WEBHOOK_SECRET --random");
441
+ console.log(" meffecta-agent set-secret AGENT_API_SECRET --random");
285
442
  return 0;
286
443
  }
287
444
  const width = Math.max(...secrets.map((name) => name.length));
@@ -318,7 +475,7 @@ async function triggers() {
318
475
 
319
476
  if (rows.length === 0) {
320
477
  console.log("No triggers. If this service is scaled to zero it is running nothing:");
321
- console.log(" meffecta-agent setup-scheduler");
478
+ console.log(" meffecta-agent sync-triggers");
322
479
  return 0;
323
480
  }
324
481
  const width = Math.max(...rows.map(([id]) => id.length));
@@ -341,7 +498,7 @@ async function triggers() {
341
498
  ).split("\t");
342
499
  console.log(`\nTask queue ${d.SERVICE}-runs: ${queue[0] ?? "?"}, ${queue[1] || 0} task(s) waiting`);
343
500
  } catch {
344
- console.log(`\nTask queue ${d.SERVICE}-runs: not created — run setup-scheduler`);
501
+ console.log(`\nTask queue ${d.SERVICE}-runs: not created — run setup-infra`);
345
502
  }
346
503
  return 0;
347
504
  }
@@ -364,8 +521,44 @@ async function logs(args) {
364
521
  }
365
522
 
366
523
  /** Start a deployment.env in the current directory, from the template in this package. */
524
+ // Scaffolded by `init` into a new content repo. Deliberately short: an empty table with the
525
+ // columns that matter is a thing an operator fills in, where a long example is a thing they
526
+ // delete.
527
+ /**
528
+ * One example entry, scaffolded so the shape is obvious from a real file rather than from
529
+ * documentation. What the fields MEAN lives in SYSTEM.base.md, because every run needs to
530
+ * know it and no content repo should be carrying its own copy of an engine rule.
531
+ */
532
+ const REGISTER_EXAMPLE = `---
533
+ system: Example — rename this file after the system, and delete it once you have a real one
534
+ skill: cloudflare
535
+ requires: CLOUDFLARE_API_KEY, CLOUDFLARE_ACCOUNT_ID
536
+ access: read; writes only when a job says so explicitly
537
+ probe: list zones
538
+ ---
539
+
540
+ One file per system, in this directory. The agent reads them **at run time**: its skills
541
+ are shared across every deployment and name no deployment's variables, so a skill says what
542
+ kind of credential it needs and comes here for the name.
543
+
544
+ **A system with no file here is one the agent will report as unavailable.** That is the
545
+ point of the register, and it is why an absence means as much as an entry.
546
+
547
+ - \`requires\` is exact variable names, all of which must be set. Use \`none\` for a system
548
+ that authenticates as the runtime service account instead.
549
+ - \`selectors\` is for optional per-project pointers — one per world.
550
+ - \`access\` is what a JOB may do, written honestly: "read-only", "may send mail", "may
551
+ change DNS". It is what a person reads when deciding whether to let a job run unattended.
552
+ - \`probe\` is the cheapest read that proves the credential works, with no side effect —
553
+ list domains rather than send, read quota rather than generate.
554
+
555
+ Set the values with \`meffecta-agent set-secret <NAME>\` (credentials) or
556
+ \`set-env <NAME>=<value>\` (ids, addresses, URLs); \`meffecta-agent connect\` lists the
557
+ systems the engine already has skills for and walks through each.
558
+ `;
559
+
367
560
  async function init(args) {
368
- const { copyFileSync, existsSync: exists } = await import("node:fs");
561
+ const { copyFileSync, existsSync: exists, writeFileSync } = await import("node:fs");
369
562
  const target = resolve(process.cwd(), "deployment.env");
370
563
  if (exists(target) && !args.includes("--force")) {
371
564
  console.log(`deployment.env already exists here. Leaving it alone (--force to replace).`);
@@ -383,6 +576,22 @@ async function init(args) {
383
576
  console.log("Fill in PROJECT and SERVICE — those two are never guessed — then commit it.");
384
577
  console.log("It belongs in your content repo, beside jobs/, because which GCP project a");
385
578
  console.log("set of jobs runs as is a fact about the deployment and none of it is secret.\n");
579
+
580
+ // The register is read by the agent itself at run time, not just by people: the skills
581
+ // are shared across deployments and name no deployment's variables, so without it a run
582
+ // knows how a system works and not which variable holds its credential.
583
+ const { mkdirSync } = await import("node:fs");
584
+ const registerDir = resolve(process.cwd(), "systems");
585
+ if (exists(registerDir)) {
586
+ console.log("systems/ already exists here. Leaving it alone.\n");
587
+ } else {
588
+ mkdirSync(registerDir, { recursive: true });
589
+ writeFileSync(resolve(registerDir, "example.md"), REGISTER_EXAMPLE);
590
+ console.log("✅ Wrote systems/example.md — the register of what this agent can reach.\n");
591
+ console.log("One file per system, added as you connect each one. The agent reads them at");
592
+ console.log("run time to find out which variable holds which credential, so a system with");
593
+ console.log("no file here is one it will report as unavailable.\n");
594
+ }
386
595
  console.log("Next: meffecta-agent steps");
387
596
  return 0;
388
597
  }
@@ -407,13 +616,17 @@ export const GROUPS = [
407
616
  ["init", "Start a deployment.env here, from the template", init],
408
617
  [
409
618
  "setup-infra",
410
- "Provision GCP: registry, buckets, service account, service shell",
619
+ "Provision GCP: registry, buckets, service account, run queue, service shell",
411
620
  script("setup-infrastructure.sh"),
412
621
  ],
413
- ["set-secret", "AGENT_WEBHOOK_SECRET --random, CLAUDE_CODE_OAUTH_TOKEN, GITHUB_TOKEN", script("set-secret.sh")],
622
+ ["set-secret", "AGENT_API_SECRET --random, CLAUDE_CODE_OAUTH_TOKEN, GITHUB_TOKEN", script("set-secret.sh")],
414
623
  ["set-env", "GIT_REPO_URL — the one setting the service will not boot without", script("set-env.sh")],
415
624
  ["deploy", "Roll out an engine image and assert the runtime shape", script("deploy.sh")],
416
- ["setup-scheduler", "Create the external triggers — Cloud Scheduler + Cloud Tasks", script("setup-scheduler.sh")],
625
+ [
626
+ "sync-triggers",
627
+ "Re-sync the Cloud Scheduler triggers without a rollout (deploy does it)",
628
+ script("sync-triggers.sh"),
629
+ ],
417
630
  ],
418
631
  },
419
632
  {
@@ -447,15 +660,24 @@ export const GROUPS = [
447
660
  ["secrets", "What is in Secret Manager, and whether the service reads it", secretsList],
448
661
  ["triggers", "The Cloud Scheduler jobs and task queue that drive it", triggers],
449
662
  ["logs", "Recent service logs (--limit N)", logs],
663
+ ["check-jobs", "Validate the job files here — after you have edited one by hand", checkJobs],
664
+ ["analytics", "What anonymous usage data is sent, and how to turn it off", analytics],
665
+ ["resources", "Everything the set-up built in Google Cloud, and what each part is for", resources],
450
666
  ],
451
667
  },
452
668
  {
453
669
  title: "Operate it",
454
670
  commands: [
671
+ ["create-job", 'Write a new job from a description: create-job "a report every Wednesday 2pm"', createJob],
455
672
  ["run", "Trigger one job now, or --in <seconds>", runJob],
456
673
  ["ask", "Ask it something as a one-off run", ask],
457
674
  ["sweep", "Run the housekeeping sweep now", sweep],
458
- ["verify-credentials", "Exercise every configured credential from here", script("verify-credentials.mjs")],
675
+ [
676
+ "verify-credentials",
677
+ "Test every credential the deployment holds, from inside it (--quick skips the slow half)",
678
+ verifyCredentials(ask),
679
+ ],
680
+ ["upgrade", "Check for a newer CLI, and what your deployment is running", upgrade],
459
681
  ["update-tooling", "Refresh the shipped scripts from an engine image (docker path)", script("update-tooling.sh")],
460
682
  ],
461
683
  },