@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.
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();
@@ -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
  }
@@ -367,22 +524,35 @@ async function logs(args) {
367
524
  // Scaffolded by `init` into a new content repo. Deliberately short: an empty table with the
368
525
  // columns that matter is a thing an operator fills in, where a long example is a thing they
369
526
  // delete.
370
- const REGISTER_TEMPLATE = `# Deployment environment
371
-
372
- The register of systems this agent can reach. The agent reads this file **at run time**
373
- its skills are shared across deployments and name no deployment's variables, so a skill
374
- says what kind of credential it needs and comes here for the name.
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
+ ---
375
539
 
376
- A system that is not listed here is one the agent will report as unavailable.
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.
377
543
 
378
- | System | Skill | Variables | Access |
379
- | --- | --- | --- | --- |
380
- | | | | |
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.
381
546
 
382
- Fill the Access column honestly — "read-only", "may send mail", "may change DNS". It is
383
- what a person reads when deciding whether a job is safe to let run unattended.
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.
384
554
 
385
- Set the values themselves with \`meffecta-agent set-secret <NAME>\` (credentials) or
555
+ Set the values with \`meffecta-agent set-secret <NAME>\` (credentials) or
386
556
  \`set-env <NAME>=<value>\` (ids, addresses, URLs); \`meffecta-agent connect\` lists the
387
557
  systems the engine already has skills for and walks through each.
388
558
  `;
@@ -408,17 +578,19 @@ async function init(args) {
408
578
  console.log("set of jobs runs as is a fact about the deployment and none of it is secret.\n");
409
579
 
410
580
  // The register is read by the agent itself at run time, not just by people: the skills
411
- // are shared across deployments and name no deployment's variables, so without this file
412
- // a run knows how a system works and not which variable holds its credential.
413
- const register = resolve(process.cwd(), "ENVIRONMENT.md");
414
- if (exists(register)) {
415
- console.log("ENVIRONMENT.md already exists here. Leaving it alone.\n");
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");
416
587
  } else {
417
- writeFileSync(register, REGISTER_TEMPLATE);
418
- console.log("✅ Wrote ENVIRONMENT.md here — the register of systems this agent can reach.\n");
419
- console.log("Add a row per system as you connect one. The agent reads this file at run");
420
- console.log("time to find out which variable holds which credential, so a system that is");
421
- console.log("not listed is a system it will report as unavailable.\n");
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");
422
594
  }
423
595
  console.log("Next: meffecta-agent steps");
424
596
  return 0;
@@ -444,13 +616,17 @@ export const GROUPS = [
444
616
  ["init", "Start a deployment.env here, from the template", init],
445
617
  [
446
618
  "setup-infra",
447
- "Provision GCP: registry, buckets, service account, service shell",
619
+ "Provision GCP: registry, buckets, service account, run queue, service shell",
448
620
  script("setup-infrastructure.sh"),
449
621
  ],
450
622
  ["set-secret", "AGENT_API_SECRET --random, CLAUDE_CODE_OAUTH_TOKEN, GITHUB_TOKEN", script("set-secret.sh")],
451
623
  ["set-env", "GIT_REPO_URL — the one setting the service will not boot without", script("set-env.sh")],
452
624
  ["deploy", "Roll out an engine image and assert the runtime shape", script("deploy.sh")],
453
- ["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
+ ],
454
630
  ],
455
631
  },
456
632
  {
@@ -484,15 +660,24 @@ export const GROUPS = [
484
660
  ["secrets", "What is in Secret Manager, and whether the service reads it", secretsList],
485
661
  ["triggers", "The Cloud Scheduler jobs and task queue that drive it", triggers],
486
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],
487
666
  ],
488
667
  },
489
668
  {
490
669
  title: "Operate it",
491
670
  commands: [
671
+ ["create-job", 'Write a new job from a description: create-job "a report every Wednesday 2pm"', createJob],
492
672
  ["run", "Trigger one job now, or --in <seconds>", runJob],
493
673
  ["ask", "Ask it something as a one-off run", ask],
494
674
  ["sweep", "Run the housekeeping sweep now", sweep],
495
- ["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],
496
681
  ["update-tooling", "Refresh the shipped scripts from an engine image (docker path)", script("update-tooling.sh")],
497
682
  ],
498
683
  },