@intentius/chant-lexicon-fly 0.16.0 → 0.18.0

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.
Files changed (65) hide show
  1. package/README.md +2 -2
  2. package/dist/composites/fly-deploy.d.ts +1 -1
  3. package/dist/composites/fly-deploy.d.ts.map +1 -1
  4. package/dist/coverage.d.ts +15 -0
  5. package/dist/coverage.d.ts.map +1 -0
  6. package/dist/emulator-freshness-cli.d.ts +11 -0
  7. package/dist/emulator-freshness-cli.d.ts.map +1 -0
  8. package/dist/emulator-freshness.d.ts +38 -0
  9. package/dist/emulator-freshness.d.ts.map +1 -0
  10. package/dist/index.d.ts +1 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/mcp/context-tools.d.ts +15 -0
  13. package/dist/mcp/context-tools.d.ts.map +1 -0
  14. package/dist/op/activities/emulator-images.d.ts +20 -0
  15. package/dist/op/activities/emulator-images.d.ts.map +1 -0
  16. package/dist/op/activities/flaps.d.ts +1 -1
  17. package/dist/op/activities/flaps.d.ts.map +1 -1
  18. package/dist/op/activities/index.d.ts +4 -0
  19. package/dist/op/activities/index.d.ts.map +1 -1
  20. package/dist/op/activities/machines-contract.d.ts +36 -0
  21. package/dist/op/activities/machines-contract.d.ts.map +1 -0
  22. package/dist/op/activities/sprites-contract.d.ts +45 -0
  23. package/dist/op/activities/sprites-contract.d.ts.map +1 -0
  24. package/dist/op/activities/sprites-emulator.d.ts +29 -0
  25. package/dist/op/activities/sprites-emulator.d.ts.map +1 -0
  26. package/dist/op/activities/sprites-fake.d.ts +62 -0
  27. package/dist/op/activities/sprites-fake.d.ts.map +1 -0
  28. package/dist/op/activities/sprites.d.ts +195 -0
  29. package/dist/op/activities/sprites.d.ts.map +1 -0
  30. package/dist/plugin.d.ts.map +1 -1
  31. package/dist/reference-catalog.d.ts +21 -0
  32. package/dist/reference-catalog.d.ts.map +1 -0
  33. package/package.json +6 -2
  34. package/src/composites/fly-deploy.ts +1 -1
  35. package/src/coverage.ts +49 -0
  36. package/src/emulator-freshness-cli.ts +49 -0
  37. package/src/emulator-freshness.test.ts +86 -0
  38. package/src/emulator-freshness.ts +87 -0
  39. package/src/index.ts +15 -0
  40. package/src/mcp/context-tools.test.ts +27 -0
  41. package/src/mcp/context-tools.ts +120 -0
  42. package/src/op/activities/emulator-images.ts +21 -0
  43. package/src/op/activities/flaps.test.ts +2 -1
  44. package/src/op/activities/flaps.ts +3 -2
  45. package/src/op/activities/index.ts +53 -0
  46. package/src/op/activities/machines-contract.docker.integration.test.ts +72 -0
  47. package/src/op/activities/machines-contract.test.ts +49 -0
  48. package/src/op/activities/machines-contract.ts +73 -0
  49. package/src/op/activities/sprites-contract.docker.integration.test.ts +74 -0
  50. package/src/op/activities/sprites-contract.test.ts +60 -0
  51. package/src/op/activities/sprites-contract.ts +61 -0
  52. package/src/op/activities/sprites-emulator.ts +46 -0
  53. package/src/op/activities/sprites-fake.ts +314 -0
  54. package/src/op/activities/sprites.docker.integration.test.ts +99 -0
  55. package/src/op/activities/sprites.integration.test.ts +158 -0
  56. package/src/op/activities/sprites.real.test.ts +56 -0
  57. package/src/op/activities/sprites.test.ts +296 -0
  58. package/src/op/activities/sprites.ts +527 -0
  59. package/src/plugin.ts +28 -5
  60. package/src/reference-catalog.test.ts +50 -0
  61. package/src/reference-catalog.ts +39 -0
  62. package/src/skills/chant-fly-patterns.md +2 -2
  63. package/src/skills/chant-fly-sprites.md +104 -0
  64. package/src/skills/chant-fly.md +2 -2
  65. package/src/generated/.gitkeep +0 -0
@@ -0,0 +1,21 @@
1
+ import type { ReferenceCatalog } from "@intentius/chant/lexicon";
2
+ /**
3
+ * Fly reference catalog (#804) — how observed Fly resources reference each other,
4
+ * so `chant graph --live` reconstructs the topology and draws the App as a
5
+ * boundary box (the reference resolver, chant#778; containment, chant#779).
6
+ *
7
+ * Fly's relationships are app-centric and simple. `describeResources`
8
+ * (./describe-resources.ts) already returns the rich per-resource shape — each
9
+ * app-scoped resource carries its owning `app`, and a machine carries its full
10
+ * `config` (with `mounts`) — so, unlike AWS (chant#784), Fly needs **no**
11
+ * `enrichLiveAttrs`: the references are already in the observed attributes.
12
+ *
13
+ * Keyed to the `describeResources` attribute shape:
14
+ * - App → `{ app }` (physicalId = app name)
15
+ * - Machine → `{ app, config }` (config.mounts[].volume references a Volume)
16
+ * - Volume → `{ app, volumeName }`
17
+ * - IPAddress → `{ app, ... }`
18
+ * - Certificate→ `{ app, ... }`
19
+ */
20
+ export declare const flyReferenceCatalog: ReferenceCatalog;
21
+ //# sourceMappingURL=reference-catalog.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reference-catalog.d.ts","sourceRoot":"","sources":["../src/reference-catalog.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAEjE;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,mBAAmB,EAAE,gBAkBjC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-fly",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src/",
@@ -47,13 +47,17 @@
47
47
  "prepack": "npm run generate && npm run bundle && npm run validate && npm run build",
48
48
  "build": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json && find dist -type f \\( -name \"*.js\" -o -name \"*.js.map\" \\) -delete"
49
49
  },
50
+ "dependencies": {
51
+ "ws": "^8.18.0"
52
+ },
50
53
  "devDependencies": {
51
54
  "@intentius/chant": "*",
55
+ "@types/ws": "^8.5.12",
52
56
  "tsc-alias": "^1.8.17",
53
57
  "typescript": "^5.9.3"
54
58
  },
55
59
  "peerDependencies": {
56
- "@intentius/chant": "^0.16.0"
60
+ "@intentius/chant": "^0.18.0"
57
61
  },
58
62
  "description": "Google Cloud lexicon for chant — declarative IaC in TypeScript",
59
63
  "license": "Apache-2.0",
@@ -36,7 +36,7 @@ export interface FlapsStepOpts {
36
36
  name?: string;
37
37
  /** Host port mapped to the emulator's `:4280`. Default: `4280`. */
38
38
  port?: number;
39
- /** Image. Default: `ghcr.io/intentius/mudflaps:0.3.0`. */
39
+ /** Image. Default: the pinned mudflaps image (`MUDFLAPS_IMAGE`, from the flapsUp activity). */
40
40
  image?: string;
41
41
  /** Readiness timeout in ms. */
42
42
  timeoutMs?: number;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Coverage analysis for the fly lexicon (#804) — how much of the generated
3
+ * Machines API surface the lexicon's typed resources cover. Thin wrapper over
4
+ * the shared `computeCoverage`, mirroring the docker/gcp/github peers.
5
+ */
6
+
7
+ import { existsSync, readFileSync } from "fs";
8
+ import { join, dirname } from "path";
9
+ import { fileURLToPath } from "url";
10
+ import {
11
+ computeCoverage,
12
+ overallPct,
13
+ formatSummary,
14
+ formatVerbose,
15
+ checkThresholds,
16
+ type CoverageReport,
17
+ type CoverageThresholds,
18
+ } from "@intentius/chant/codegen/coverage";
19
+
20
+ export type { CoverageReport, CoverageThresholds };
21
+ export { computeCoverage, overallPct, formatSummary, formatVerbose, checkThresholds };
22
+
23
+ /** Run coverage analysis for the fly lexicon. */
24
+ export async function analyzeFlyCoverage(opts?: {
25
+ basePath?: string;
26
+ verbose?: boolean;
27
+ minOverall?: number;
28
+ }): Promise<CoverageReport> {
29
+ const basePath = opts?.basePath ?? dirname(dirname(fileURLToPath(import.meta.url)));
30
+ const lexiconPath = join(basePath, "src", "generated", "lexicon-fly.json");
31
+
32
+ if (!existsSync(lexiconPath)) {
33
+ throw new Error(`Generated lexicon not found at ${lexiconPath}. Run "chant dev generate" first.`);
34
+ }
35
+
36
+ const report = computeCoverage(readFileSync(lexiconPath, "utf-8"));
37
+
38
+ console.error(opts?.verbose ? formatVerbose(report) : formatSummary(report));
39
+
40
+ if (typeof opts?.minOverall === "number") {
41
+ const result = checkThresholds(report, { minOverallPct: opts.minOverall });
42
+ if (!result.ok) {
43
+ for (const v of result.violations) console.error(` FAIL: ${v}`);
44
+ throw new Error("Coverage below threshold");
45
+ }
46
+ }
47
+
48
+ return report;
49
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * CLI for the emulator-freshness check (#808 T2). Fetches the latest mudflaps /
3
+ * spritzer releases, compares to the pinned tags, prints a report, and — when
4
+ * run in CI — writes `behind` + a Markdown `body` to `$GITHUB_OUTPUT` so the
5
+ * weekly workflow can open/refresh a single "N releases behind" notice issue.
6
+ *
7
+ * Advisory only: exits 0 whether or not a pin is behind (the bump is a human
8
+ * decision per the #808 policy). A hard failure (network/API) exits 1.
9
+ */
10
+
11
+ import { appendFileSync } from "node:fs";
12
+ import { checkFreshness, formatResult, type FreshnessResult } from "./emulator-freshness";
13
+
14
+ function issueBody(behind: FreshnessResult[]): string {
15
+ const rows = behind.map((r) => `- **${r.name}** — pinned \`${r.pinned}\`, latest \`${r.latest}\``).join("\n");
16
+ return [
17
+ "The pinned Fly emulator image(s) are behind their latest upstream release:",
18
+ "",
19
+ rows,
20
+ "",
21
+ "The tag lives in `lexicons/fly/src/op/activities/emulator-images.ts` (single source).",
22
+ "",
23
+ "Per the #808 bump policy this is **advisory** — move the pin only when a consuming",
24
+ "test needs the newer emulator (a fidelity fix the fly activities exercise), not on",
25
+ "every release. Close this issue once reviewed or bumped.",
26
+ ].join("\n");
27
+ }
28
+
29
+ async function main(): Promise<void> {
30
+ const results = await checkFreshness();
31
+ for (const r of results) console.error(formatResult(r));
32
+
33
+ const behind = results.filter((r) => r.behind);
34
+ console.log(JSON.stringify({ behind: behind.length > 0, results }, null, 2));
35
+
36
+ const out = process.env.GITHUB_OUTPUT;
37
+ if (out) {
38
+ appendFileSync(out, `behind=${behind.length > 0}\n`);
39
+ if (behind.length > 0) {
40
+ // Multiline output via the GITHUB_OUTPUT heredoc form.
41
+ appendFileSync(out, `body<<FRESHNESS_EOF\n${issueBody(behind)}\nFRESHNESS_EOF\n`);
42
+ }
43
+ }
44
+ }
45
+
46
+ main().catch((err: unknown) => {
47
+ console.error(err instanceof Error ? err.message : String(err));
48
+ process.exit(1);
49
+ });
@@ -0,0 +1,86 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import {
3
+ parseVersion,
4
+ compare,
5
+ latestRelease,
6
+ checkFreshness,
7
+ formatResult,
8
+ EMULATOR_PINS,
9
+ } from "./emulator-freshness";
10
+
11
+ describe("parseVersion", () => {
12
+ test("extracts the version from a ghcr image ref, stripping a leading v", () => {
13
+ expect(parseVersion("ghcr.io/intentius/mudflaps:0.3.1")).toBe("0.3.1");
14
+ expect(parseVersion("ghcr.io/intentius/spritzer:v1.2.0")).toBe("1.2.0");
15
+ });
16
+ });
17
+
18
+ describe("compare", () => {
19
+ test("not behind when pinned equals latest", () => {
20
+ expect(compare("mudflaps", "0.3.1", "v0.3.1").behind).toBe(false);
21
+ });
22
+ test("behind when latest is a newer patch/minor/major", () => {
23
+ expect(compare("m", "0.3.1", "v0.3.2").behind).toBe(true);
24
+ expect(compare("m", "0.3.1", "v0.4.0").behind).toBe(true);
25
+ expect(compare("m", "0.3.1", "v1.0.0").behind).toBe(true);
26
+ });
27
+ test("not behind when pinned is ahead of latest", () => {
28
+ expect(compare("m", "0.4.0", "v0.3.9").behind).toBe(false);
29
+ });
30
+ test("normalizes the leading v out of the reported versions", () => {
31
+ const r = compare("m", "0.3.1", "v0.3.2");
32
+ expect(r.pinned).toBe("0.3.1");
33
+ expect(r.latest).toBe("0.3.2");
34
+ });
35
+ });
36
+
37
+ describe("EMULATOR_PINS", () => {
38
+ test("tracks mudflaps and spritzer from the single-source image constants", () => {
39
+ expect(EMULATOR_PINS.map((p) => p.name).sort()).toEqual(["mudflaps", "spritzer"]);
40
+ for (const p of EMULATOR_PINS) {
41
+ expect(p.repo).toMatch(/^intentius\//);
42
+ expect(p.pinned).toMatch(/^\d+\.\d+\.\d+$/);
43
+ }
44
+ });
45
+ });
46
+
47
+ describe("latestRelease + checkFreshness (mocked fetch)", () => {
48
+ const mkFetch = (tags: Record<string, string>): typeof fetch =>
49
+ (async (url: string | URL | Request) => {
50
+ const u = String(url);
51
+ const repo = u.match(/repos\/([^/]+\/[^/]+)\/releases/)?.[1] ?? "";
52
+ return { ok: true, status: 200, json: async () => ({ tag_name: tags[repo] }) } as Response;
53
+ }) as unknown as typeof fetch;
54
+
55
+ test("latestRelease returns the tag_name", async () => {
56
+ const f = mkFetch({ "intentius/mudflaps": "v0.9.0" });
57
+ expect(await latestRelease("intentius/mudflaps", f)).toBe("v0.9.0");
58
+ });
59
+
60
+ test("checkFreshness flags a pin behind its latest release", async () => {
61
+ // Force both upstreams to a high version so the check reports behind
62
+ // regardless of the currently-pinned tag.
63
+ const f = mkFetch({ "intentius/mudflaps": "v99.0.0", "intentius/spritzer": "v99.0.0" });
64
+ const results = await checkFreshness(f);
65
+ expect(results).toHaveLength(2);
66
+ expect(results.every((r) => r.behind)).toBe(true);
67
+ });
68
+
69
+ test("checkFreshness reports current when latest matches the pin", async () => {
70
+ const pins = Object.fromEntries(EMULATOR_PINS.map((p) => [p.repo, `v${p.pinned}`]));
71
+ const results = await checkFreshness(mkFetch(pins));
72
+ expect(results.every((r) => !r.behind)).toBe(true);
73
+ });
74
+
75
+ test("latestRelease throws on a non-ok response", async () => {
76
+ const f = (async () => ({ ok: false, status: 404 }) as Response) as unknown as typeof fetch;
77
+ await expect(latestRelease("intentius/nope", f)).rejects.toThrow(/HTTP 404/);
78
+ });
79
+ });
80
+
81
+ describe("formatResult", () => {
82
+ test("marks behind vs current distinctly", () => {
83
+ expect(formatResult({ name: "m", pinned: "0.3.1", latest: "0.4.0", behind: true })).toMatch(/behind/);
84
+ expect(formatResult({ name: "m", pinned: "0.3.1", latest: "0.3.1", behind: false })).toMatch(/current/);
85
+ });
86
+ });
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Emulator-freshness check (#808 T2, second half).
3
+ *
4
+ * mudflaps (Machines) and spritzer (Sprites) are pinned to a single source
5
+ * (./op/activities/emulator-images.ts). Their upstream repos cut GitHub releases,
6
+ * so this compares each pinned tag against the latest release and reports how far
7
+ * behind it is. A weekly workflow surfaces a "N releases behind" notice — never
8
+ * an auto-bump. Per the bump policy (#808), the tag moves only when a consuming
9
+ * test needs the newer emulator, so this is advisory, not gating on the pin.
10
+ */
11
+
12
+ import { MUDFLAPS_IMAGE, SPRITZER_IMAGE } from "./op/activities/emulator-images";
13
+
14
+ export interface EmulatorPin {
15
+ /** Short name, e.g. "mudflaps". */
16
+ name: string;
17
+ /** GitHub repo "owner/name" whose releases publish the emulator. */
18
+ repo: string;
19
+ /** Pinned version, no leading "v" (e.g. "0.3.1"). */
20
+ pinned: string;
21
+ }
22
+
23
+ export interface FreshnessResult {
24
+ name: string;
25
+ pinned: string;
26
+ latest: string;
27
+ /** True when the latest release is newer than the pinned version. */
28
+ behind: boolean;
29
+ }
30
+
31
+ /** Parse the version tag from a ghcr image ref (".../mudflaps:0.3.1" → "0.3.1"). */
32
+ export function parseVersion(image: string): string {
33
+ const tag = image.slice(image.lastIndexOf(":") + 1);
34
+ return tag.replace(/^v/, "");
35
+ }
36
+
37
+ function toParts(v: string): number[] {
38
+ return v.replace(/^v/, "").split(".").map((n) => parseInt(n, 10) || 0);
39
+ }
40
+
41
+ /** Compare pinned vs latest as dotted numeric versions; `behind` when latest > pinned. */
42
+ export function compare(name: string, pinned: string, latest: string): FreshnessResult {
43
+ const p = toParts(pinned);
44
+ const l = toParts(latest);
45
+ let behind = false;
46
+ for (let i = 0; i < Math.max(p.length, l.length); i++) {
47
+ const a = p[i] ?? 0;
48
+ const b = l[i] ?? 0;
49
+ if (b > a) {
50
+ behind = true;
51
+ break;
52
+ }
53
+ if (b < a) break;
54
+ }
55
+ return { name, pinned: pinned.replace(/^v/, ""), latest: latest.replace(/^v/, ""), behind };
56
+ }
57
+
58
+ /** The emulator pins to check, read from the single-source image constants. */
59
+ export const EMULATOR_PINS: readonly EmulatorPin[] = [
60
+ { name: "mudflaps", repo: "intentius/mudflaps", pinned: parseVersion(MUDFLAPS_IMAGE) },
61
+ { name: "spritzer", repo: "intentius/spritzer", pinned: parseVersion(SPRITZER_IMAGE) },
62
+ ] as const;
63
+
64
+ /** Fetch the latest release tag for a repo via the GitHub REST API. */
65
+ export async function latestRelease(repo: string, fetchImpl: typeof fetch = fetch): Promise<string> {
66
+ const headers: Record<string, string> = { Accept: "application/vnd.github+json" };
67
+ if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
68
+ const res = await fetchImpl(`https://api.github.com/repos/${repo}/releases/latest`, { headers });
69
+ if (!res.ok) throw new Error(`releases/latest ${repo}: HTTP ${res.status}`);
70
+ const body = (await res.json()) as { tag_name?: string };
71
+ if (!body.tag_name) throw new Error(`releases/latest ${repo}: no tag_name`);
72
+ return body.tag_name;
73
+ }
74
+
75
+ /** Check every emulator pin against its latest release. */
76
+ export async function checkFreshness(fetchImpl: typeof fetch = fetch): Promise<FreshnessResult[]> {
77
+ return Promise.all(
78
+ EMULATOR_PINS.map(async (p) => compare(p.name, p.pinned, await latestRelease(p.repo, fetchImpl))),
79
+ );
80
+ }
81
+
82
+ /** One-line human summary of a result. */
83
+ export function formatResult(r: FreshnessResult): string {
84
+ return r.behind
85
+ ? `⚠ ${r.name}: pinned ${r.pinned}, latest ${r.latest} — behind`
86
+ : `✓ ${r.name}: pinned ${r.pinned} is current (latest ${r.latest})`;
87
+ }
package/src/index.ts CHANGED
@@ -17,6 +17,21 @@ export { FLY_METADATA_OWNERSHIP_KEYS } from "./ownership";
17
17
  export { flyDeploy, flapsUp, flapsDown, flyApplyStep, LOCAL_FLAPS_ENDPOINT } from "./composites/fly-deploy";
18
18
  export type { FlyDeployOpts, FlyApplyStepOpts, FlapsStepOpts } from "./composites/fly-deploy";
19
19
 
20
+ // Sprite Op step builders (re-exported from core for single-import convenience).
21
+ // These author `activity("spriteCreate", ...)` steps; `loadActivities(["fly"])`
22
+ // binds them to the implementations in ./op/activities/sprites.ts. The `spritesUp`
23
+ // /`spritesDown` builders boot/tear down the spritzer emulator as modeled steps.
24
+ export {
25
+ spriteCreate,
26
+ spriteExec,
27
+ spriteCheckpoint,
28
+ spriteRestore,
29
+ listCheckpoints,
30
+ spriteDestroy,
31
+ spritesUp,
32
+ spritesDown,
33
+ } from "@intentius/chant/op";
34
+
20
35
  // Generated resources — export everything from generated index.
21
36
  // Provides `App`, `Machine`, `Volume`, and the property types
22
37
  // (`MachineConfig`, `MachineGuest`, `MachineService`, ...) for authoring.
@@ -0,0 +1,27 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { flyContextTools, imagePinned } from "./context-tools";
3
+
4
+ describe("flyContextTools", () => {
5
+ const tools = flyContextTools();
6
+
7
+ it("exposes the read-only fly:* context tools", () => {
8
+ expect(tools.map((t) => t.name).sort()).toEqual(["fly:app", "fly:checks", "fly:plan", "fly:references"]);
9
+ });
10
+
11
+ it("every tool has a description, a path input, and a handler", () => {
12
+ for (const t of tools) {
13
+ expect(t.description.length).toBeGreaterThan(10);
14
+ expect(t.inputSchema.type).toBe("object");
15
+ expect(t.inputSchema.properties).toHaveProperty("path");
16
+ expect(typeof t.handler).toBe("function");
17
+ }
18
+ });
19
+ });
20
+
21
+ describe("imagePinned", () => {
22
+ it("is pinned only when the image names a digest", () => {
23
+ expect(imagePinned("flyio/hellofly@sha256:abc123")).toBe(true);
24
+ expect(imagePinned("flyio/hellofly:latest")).toBe(false);
25
+ expect(imagePinned("flyio/hellofly")).toBe(false);
26
+ });
27
+ });
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Read-only MCP tools that expose what `chant build` already computes about a
3
+ * Fly deploy — its apps and machines, the flaps create bodies, image pinning,
4
+ * and the lexicon's findings — so an agent can ask about a change *before it
5
+ * runs* (#804).
6
+ *
7
+ * Every tool builds from source and returns data. None touch a live Fly org,
8
+ * read run history, or write anything — the same context-producer boundary the
9
+ * AWS/GitLab context tools hold.
10
+ */
11
+
12
+ import { build, type BuildResult } from "@intentius/chant/build";
13
+ import type { SerializerResult } from "@intentius/chant/serializer";
14
+ import { runPostSynthChecks, getPrimaryOutput } from "@intentius/chant/lint/post-synth";
15
+ import { postSynthChecks } from "../lint/post-synth";
16
+ import type { McpToolContribution } from "@intentius/chant/mcp/types";
17
+ import { flySerializer } from "../serializer";
18
+ import { parsePlan, isAppRequest, isMachineRequest, type FlyPlan, type FlapsRequest } from "../op/activities/fly-apply";
19
+
20
+ const PATH_INPUT = {
21
+ type: "object" as const,
22
+ properties: {
23
+ path: { type: "string", description: "Path to the chant project directory (default: current directory)" },
24
+ },
25
+ };
26
+
27
+ /** Build the project and parse the flaps plan the serializer emits. */
28
+ async function buildFly(path: string): Promise<{ plan: FlyPlan; result: BuildResult; output?: string | SerializerResult }> {
29
+ const result = await build(path || ".", [flySerializer]);
30
+ const output = result.outputs.get("fly");
31
+ let plan: FlyPlan = {};
32
+ if (output) {
33
+ try {
34
+ plan = parsePlan(getPrimaryOutput(output));
35
+ } catch {
36
+ // not a parseable fly plan — leave empty
37
+ }
38
+ }
39
+ return { plan, result, output };
40
+ }
41
+
42
+ const machineImage = (req: FlapsRequest): string | undefined => {
43
+ const config = req.body.config as { image?: unknown } | undefined;
44
+ return typeof config?.image === "string" ? config.image : undefined;
45
+ };
46
+
47
+ /** A container image reference is pinned when it names a digest, not a tag. */
48
+ export function imagePinned(image: string): boolean {
49
+ return image.includes("@sha256:");
50
+ }
51
+
52
+ export function flyContextTools(): McpToolContribution[] {
53
+ return [
54
+ {
55
+ name: "fly:app",
56
+ description:
57
+ "Build the project and summarize the declared Fly apps and machines — regions, images, guest sizing — from source. Read-only; never touches a live Fly org.",
58
+ inputSchema: PATH_INPUT,
59
+ async handler(params: Record<string, unknown>): Promise<unknown> {
60
+ const { plan } = await buildFly(String(params.path ?? "."));
61
+ const apps: Array<{ name: string; org_slug: unknown }> = [];
62
+ const machines: Array<{ name: string; region: unknown; image: string | null; guest: unknown }> = [];
63
+ for (const [name, req] of Object.entries(plan)) {
64
+ if (isAppRequest(req)) {
65
+ apps.push({ name: String(req.body.app_name ?? name), org_slug: req.body.org_slug ?? null });
66
+ } else if (isMachineRequest(req)) {
67
+ machines.push({
68
+ name: String(req.body.name ?? name),
69
+ region: req.body.region ?? null,
70
+ image: machineImage(req) ?? null,
71
+ guest: (req.body.config as { guest?: unknown } | undefined)?.guest ?? null,
72
+ });
73
+ }
74
+ }
75
+ return { apps, machines };
76
+ },
77
+ },
78
+ {
79
+ name: "fly:checks",
80
+ description:
81
+ "Build the project and return the fly lexicon's post-synth findings (machine requires an image, a mount references a declared Volume) as JSON. Read-only.",
82
+ inputSchema: PATH_INPUT,
83
+ async handler(params: Record<string, unknown>): Promise<unknown> {
84
+ const { output, result } = await buildFly(String(params.path ?? "."));
85
+ if (!output) return { findings: [], note: "no Fly plan produced from this project" };
86
+ const scoped: BuildResult = { ...result, outputs: new Map([["fly", output]]) };
87
+ const diags = runPostSynthChecks(postSynthChecks, scoped);
88
+ return {
89
+ findings: diags.map((d) => ({ id: d.checkId, severity: d.severity, entity: d.entity ?? null, message: d.message })),
90
+ };
91
+ },
92
+ },
93
+ {
94
+ name: "fly:references",
95
+ description:
96
+ "Build the project and list each machine's container image and whether it is pinned to a digest (vs a floating tag). Read-only.",
97
+ inputSchema: PATH_INPUT,
98
+ async handler(params: Record<string, unknown>): Promise<unknown> {
99
+ const { plan } = await buildFly(String(params.path ?? "."));
100
+ const images: Array<{ machine: string; image: string; pinned: boolean }> = [];
101
+ for (const [name, req] of Object.entries(plan)) {
102
+ if (!isMachineRequest(req)) continue;
103
+ const image = machineImage(req);
104
+ if (image) images.push({ machine: String(req.body.name ?? name), image, pinned: imagePinned(image) });
105
+ }
106
+ return images;
107
+ },
108
+ },
109
+ {
110
+ name: "fly:plan",
111
+ description:
112
+ "Build the project and return the flaps create bodies the serializer emits — exactly what flyApply would POST to the Machines API. Read-only.",
113
+ inputSchema: PATH_INPUT,
114
+ async handler(params: Record<string, unknown>): Promise<unknown> {
115
+ const { plan } = await buildFly(String(params.path ?? "."));
116
+ return plan;
117
+ },
118
+ },
119
+ ];
120
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Single source of truth for the Fly emulator image pins (#808 T2).
3
+ *
4
+ * mudflaps (Machines API) and spritzer (Sprites API) are the local fakes fly's
5
+ * activities are exercised against. These tags were previously duplicated across
6
+ * flaps.ts, sprites-emulator.ts, the fly-deploy composite, and tests — and had
7
+ * already drifted (the composite's docstring said mudflaps 0.3.0 while the
8
+ * activity pinned 0.3.1). Pin each here so a bump touches one line and the
9
+ * emulator-freshness check (#808 T2) has a single target to compare against the
10
+ * latest GHCR release.
11
+ *
12
+ * Bump policy (#808): move these only when a consuming test needs a newer
13
+ * emulator (a fidelity fix the activities exercise), not on every emulator
14
+ * release. For new upstream API surface the emulator leads, then fly bumps here.
15
+ */
16
+
17
+ /** Pinned mudflaps (Fly Machines / flaps emulator) image. */
18
+ export const MUDFLAPS_IMAGE = "ghcr.io/intentius/mudflaps:0.4.0";
19
+
20
+ /** Pinned spritzer (Fly Sprites emulator) image. */
21
+ export const SPRITZER_IMAGE = "ghcr.io/intentius/spritzer:0.3.1";
@@ -6,11 +6,12 @@ import {
6
6
  flapsHealthUrl,
7
7
  flapsEndpoint,
8
8
  } from "./flaps";
9
+ import { MUDFLAPS_IMAGE } from "./emulator-images";
9
10
 
10
11
  describe("flaps (mudflaps) lifecycle commands", () => {
11
12
  test("run command uses defaults and maps the port", () => {
12
13
  expect(flapsRunCommand({})).toBe(
13
- "docker run -d --rm --name chant-mudflaps -p 4280:4280 ghcr.io/intentius/mudflaps:0.3.0",
14
+ `docker run -d --rm --name chant-mudflaps -p 4280:4280 ${MUDFLAPS_IMAGE}`,
14
15
  );
15
16
  });
16
17
 
@@ -1,11 +1,12 @@
1
1
  import { emulatorLifecycle } from "@intentius/chant/op";
2
+ import { MUDFLAPS_IMAGE } from "./emulator-images";
2
3
 
3
4
  export interface FlapsUpArgs {
4
5
  /** Container name. Default: `chant-mudflaps`. */
5
6
  name?: string;
6
7
  /** Host port mapped to the emulator's `:4280`. Default: `4280`. */
7
8
  port?: number;
8
- /** Image. Default: `ghcr.io/intentius/mudflaps:0.3.0`. */
9
+ /** Image. Default: the pinned mudflaps image ({@link MUDFLAPS_IMAGE}). */
9
10
  image?: string;
10
11
  /** Readiness timeout in ms. Default: `60000`. */
11
12
  timeoutMs?: number;
@@ -23,7 +24,7 @@ export interface FlapsDownArgs {
23
24
  // with FLY_FLAPS_BASE_URL. Shared lifecycle: emulatorLifecycle (#746).
24
25
  const flaps = emulatorLifecycle({
25
26
  name: "chant-mudflaps",
26
- image: "ghcr.io/intentius/mudflaps:0.3.0",
27
+ image: MUDFLAPS_IMAGE,
27
28
  containerPort: 4280,
28
29
  healthPath: "/_mudflaps/health",
29
30
  });
@@ -46,3 +46,56 @@ export {
46
46
  flapsEndpoint,
47
47
  } from "./flaps";
48
48
  export type { FlapsUpArgs, FlapsDownArgs } from "./flaps";
49
+
50
+ // Sprites (sprites.dev) — the other Fly product: imperative, checkpointable
51
+ // sandbox activities. `loadActivities(["fly"])` provides these; the fake lives
52
+ // in `sprites-fake.ts` and is imported only by tests (not an activity). Unlike
53
+ // Machines, Sprites have no desired state to reconcile — they are runtime
54
+ // primitives driven inside an Op, with checkpoint-as-compensation as the
55
+ // headline capability.
56
+ export {
57
+ spriteCreate,
58
+ spriteExec,
59
+ spriteCheckpoint,
60
+ spriteRestore,
61
+ listCheckpoints,
62
+ spriteDestroy,
63
+ resolveSpritesEndpoint,
64
+ defaultSpritesHttp,
65
+ spriteCreateBody,
66
+ parseCreateResponse,
67
+ accumulateExecFrames,
68
+ parseCheckpointNdjson,
69
+ pickCheckpointByComment,
70
+ splitCommand,
71
+ spriteExecWsUrl,
72
+ DEFAULT_SPRITES_BASE_URL,
73
+ } from "./sprites";
74
+ export type {
75
+ SpritesHttp,
76
+ SpriteCreateArgs,
77
+ SpriteCreateResult,
78
+ SpriteExecArgs,
79
+ SpriteExecResult,
80
+ SpriteCheckpointArgs,
81
+ SpriteCheckpointResult,
82
+ SpriteRestoreArgs,
83
+ ListCheckpointsArgs,
84
+ Checkpoint,
85
+ SpriteDestroyArgs,
86
+ } from "./sprites";
87
+
88
+ // spritzer (the Sprites API emulator) Docker lifecycle — the twin of mudflaps
89
+ // above. `spritesUp`/`spritesDown` resolve by name so an Op can boot/tear down
90
+ // the emulator as a modeled step; the sprite activities target it via
91
+ // SPRITES_BASE_URL.
92
+ export {
93
+ spritesUp,
94
+ spritesDown,
95
+ spritesRunCommand,
96
+ spritesRmCommand,
97
+ spritesExistsCommand,
98
+ spritesHealthUrl,
99
+ spritesEndpoint,
100
+ } from "./sprites-emulator";
101
+ export type { SpritesUpArgs, SpritesDownArgs } from "./sprites-emulator";