@intentius/chant-lexicon-fly 0.33.0 → 0.33.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.
@@ -1 +1 @@
1
- {"version":3,"file":"package.d.ts","sourceRoot":"","sources":["../../src/codegen/package.ts"],"names":[],"mappings":"AAOA;;GAEG;AACH,wBAAsB,cAAc,CAAC,OAAO,CAAC,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE;;;;;;;;;GAsBpF"}
1
+ {"version":3,"file":"package.d.ts","sourceRoot":"","sources":["../../src/codegen/package.ts"],"names":[],"mappings":"AAOA;;GAEG;AACH,wBAAsB,cAAc,CAAC,OAAO,CAAC,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE;;;;;;;;;GA2BpF"}
@@ -1,9 +1,19 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "4266ee314839e29c00076003edd6ae24fb9f8151c75c0af340b863d219b610df",
4
+ "manifest.json": "de9565b9ee89d555d1fe40b61a1bf9517277d0ec09a40224459bc9f92001b8fd",
5
5
  "meta.json": "e46e65ba7544fafc6dbb3fd48b841904b5ae6f7cb4d517a940ed68b01626c097",
6
- "types/index.d.ts": "07f5d04d0d3831cae053b1f0f5dd661c58e9708f45067a0fc85dd94079ac144b"
6
+ "types/index.d.ts": "07f5d04d0d3831cae053b1f0f5dd661c58e9708f45067a0fc85dd94079ac144b",
7
+ "rules/guest-sizing.ts": "bc749d218df8f3cc3d8fbe388205ae96e6b204ae0d6d77e8bf82cb936cc53ff6",
8
+ "rules/no-secret-literals.ts": "4846d55118c9a66ad524b815ac28c3f548bfe114d5899e79c394f271c8b2c478",
9
+ "rules/valid-region.ts": "674550b3b112aa1e526572b319601dce0c1a84f537c05b1e07828f8b84511c75",
10
+ "rules/fly-helpers.ts": "dd29d4b520727210a56ef604a89df380f91f8b35770bc28f5c51d0f99ca92dff",
11
+ "rules/fly010-machine-requires-image.ts": "dd9b70ee604867a9f02334013476afcf44a78b49bbba3d66296b8ccc2b02c8a0",
12
+ "rules/fly011-mount-references-declared-volume.ts": "4698f73b85954a4094a1445d42336698440bf99a4004c067529c5f9fa1047cc6",
13
+ "skills/chant-fly.md": "04277509e0abd81869047e66c58a10f4c8eaaa60a56be28bfcbfbe1cce20ad7c",
14
+ "skills/chant-fly-patterns.md": "9452bf83c9e499b36a01a305a4ef28965725426436df115fd7f21e3a773f98da",
15
+ "skills/chant-fly-ops.md": "b8eef6504e203133e6a35c83aabb08404eec95060705395515a0046d42269cf1",
16
+ "skills/chant-fly-sprites.md": "89ba52db63a71a17eab483d99233a4ae67f5d34920445010e750f81c95200b4e"
7
17
  },
8
- "composite": "52d33594a553b226ab907e3e03ca39a3460f054e189ae97cf77715f1ae7d4745"
18
+ "composite": "97f716342c879b0bf1052bda5a8e894be4f4408c1fde4c713963190be3b1d340"
9
19
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fly",
3
- "version": "0.33.0",
3
+ "version": "0.33.1",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "Fly"
6
6
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Shared helpers for the fly post-synth checks. Not a check itself, so the
3
+ * generated barrel skips it (the scanner only picks up exported PostSynthChecks).
4
+ */
5
+
6
+ /**
7
+ * Read an entity or property's constructor props. Nested Declarables
8
+ * (MachineConfig, MachineMount, Volume) stash their args under a non-enumerable
9
+ * `props`; a plain inline object carries them directly. Handle both so a check
10
+ * behaves the same whether the user authored `config: { image }` or
11
+ * `config: new MachineConfig({ image })`.
12
+ */
13
+ export function readProps(value: unknown): Record<string, unknown> {
14
+ if (value && typeof value === "object") {
15
+ const nested = (value as { props?: unknown }).props;
16
+ if (nested && typeof nested === "object") return nested as Record<string, unknown>;
17
+ return value as Record<string, unknown>;
18
+ }
19
+ return {};
20
+ }
21
+
22
+ /** The entityType of a declarable, or undefined. */
23
+ export function entityTypeOf(value: unknown): string | undefined {
24
+ return (value as { entityType?: string } | undefined)?.entityType;
25
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * FLY010: Machine config must specify an image.
3
+ *
4
+ * A Machine cannot boot without a container image. The Machines API rejects a
5
+ * create request whose config omits `image`, so catch it at synth time with a
6
+ * clearer message.
7
+ */
8
+
9
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
10
+ import { readProps, entityTypeOf } from "./fly-helpers";
11
+
12
+ export const fly010: PostSynthCheck = {
13
+ id: "FLY010",
14
+ description: "Machine config must specify an image — a Machine cannot boot without one",
15
+
16
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
17
+ const diagnostics: PostSynthDiagnostic[] = [];
18
+
19
+ for (const [name, entity] of ctx.entities) {
20
+ if (entityTypeOf(entity) !== "Fly::Machines::Machine") continue;
21
+
22
+ const config = readProps(entity).config;
23
+ if (!config) continue; // no config authored — a different concern
24
+
25
+ const image = readProps(config).image;
26
+ if (typeof image !== "string" || image.length === 0) {
27
+ diagnostics.push({
28
+ checkId: "FLY010",
29
+ severity: "error",
30
+ message: `Machine "${name}" config has no image — set config.image, e.g. image: "flyio/fastify-functions"`,
31
+ entity: name,
32
+ lexicon: "fly",
33
+ });
34
+ }
35
+ }
36
+
37
+ return diagnostics;
38
+ },
39
+ };
@@ -0,0 +1,61 @@
1
+ /**
2
+ * FLY011: A machine mount must reference a declared volume.
3
+ *
4
+ * Every machine `config.mounts[].volume` must resolve to a `Volume` declared in
5
+ * the stack. A mount pointing at a volume that does not exist is rejected at
6
+ * apply time, so catch it at synth. This is a whole-stack check: it reads all
7
+ * entities in the build (`ctx.entities`), so the mount and the Volume can be
8
+ * declared in different files.
9
+ */
10
+
11
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
12
+ import { readProps, entityTypeOf } from "./fly-helpers";
13
+
14
+ const MACHINE_ENTITY_TYPE = "Fly::Machines::Machine";
15
+ const VOLUME_ENTITY_TYPE = "Fly::Machines::Volume";
16
+
17
+ export const fly011: PostSynthCheck = {
18
+ id: "FLY011",
19
+ description: "A machine mount's volume must resolve to a Volume declared in the stack",
20
+
21
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
22
+ const diagnostics: PostSynthDiagnostic[] = [];
23
+
24
+ // A mount can name a declared volume by the Volume's `name` or by its
25
+ // logical (declaration) name — collect both.
26
+ const declaredVolumes = new Set<string>();
27
+ for (const [logicalName, entity] of ctx.entities) {
28
+ if (entityTypeOf(entity) !== VOLUME_ENTITY_TYPE) continue;
29
+ declaredVolumes.add(logicalName);
30
+ const name = readProps(entity).name;
31
+ if (typeof name === "string" && name.length > 0) declaredVolumes.add(name);
32
+ }
33
+
34
+ for (const [machineName, entity] of ctx.entities) {
35
+ if (entityTypeOf(entity) !== MACHINE_ENTITY_TYPE) continue;
36
+ const config = readProps(entity).config;
37
+ if (!config) continue;
38
+ const mounts = readProps(config).mounts;
39
+ if (!Array.isArray(mounts)) continue;
40
+
41
+ for (const mount of mounts) {
42
+ const volume = readProps(mount).volume;
43
+ // Only a string names a volume by value. A Declarable/AttrRef is a live
44
+ // reference to a Volume that exists in the stack by construction, so it
45
+ // never dangles — skip it to avoid false positives.
46
+ if (typeof volume !== "string" || volume.length === 0) continue;
47
+ if (!declaredVolumes.has(volume)) {
48
+ diagnostics.push({
49
+ checkId: "FLY011",
50
+ severity: "error",
51
+ message: `Machine "${machineName}" mounts volume "${volume}", which is not declared as a Volume in the stack.`,
52
+ entity: machineName,
53
+ lexicon: "fly",
54
+ });
55
+ }
56
+ }
57
+ }
58
+
59
+ return diagnostics;
60
+ },
61
+ };
@@ -0,0 +1,100 @@
1
+ import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
2
+ import * as ts from "typescript";
3
+
4
+ /**
5
+ * Fly guest presets, per `cpu_kind`.
6
+ *
7
+ * `cpus` is the set of valid CPU counts; `memory_mb` must be a multiple of 256
8
+ * within [minPerCpu * cpus, maxPerCpu * cpus]. Values mirror Fly's published
9
+ * guest presets (shared-cpu-Nx / performance-Nx): shared allows 256–2048 MB per
10
+ * CPU, performance allows 2048–8192 MB per CPU. The 256 MB step is the loosest
11
+ * safe multiple, so a valid config is never flagged; only clearly-invalid combos
12
+ * are. fly-go was not vendored in this tree to cross-check against, so this table
13
+ * is a documented representative set (a limitation, widen if Fly changes presets).
14
+ */
15
+ const GUEST_PRESETS: Record<string, { cpus: Set<number>; minPerCpu: number; maxPerCpu: number }> = {
16
+ shared: { cpus: new Set([1, 2, 4, 8]), minPerCpu: 256, maxPerCpu: 2048 },
17
+ performance: { cpus: new Set([1, 2, 4, 8, 16]), minPerCpu: 2048, maxPerCpu: 8192 },
18
+ };
19
+
20
+ const MEMORY_STEP_MB = 256;
21
+
22
+ /** Read a numeric property from an object literal, if present as a plain number literal. */
23
+ function numberProp(obj: ts.ObjectLiteralExpression, key: string, sf: ts.SourceFile): number | undefined {
24
+ for (const prop of obj.properties) {
25
+ if (ts.isPropertyAssignment(prop) && prop.name.getText(sf) === key && ts.isNumericLiteral(prop.initializer)) {
26
+ return Number(prop.initializer.text);
27
+ }
28
+ }
29
+ return undefined;
30
+ }
31
+
32
+ /** Read a string property from an object literal, if present as a plain string literal. */
33
+ function stringProp(obj: ts.ObjectLiteralExpression, key: string, sf: ts.SourceFile): string | undefined {
34
+ for (const prop of obj.properties) {
35
+ if (ts.isPropertyAssignment(prop) && prop.name.getText(sf) === key && ts.isStringLiteral(prop.initializer)) {
36
+ return prop.initializer.text;
37
+ }
38
+ }
39
+ return undefined;
40
+ }
41
+
42
+ /**
43
+ * FLY002: Sane guest sizing
44
+ *
45
+ * A guest's `cpu_kind`, `cpus`, and `memory_mb` must be a valid combination per
46
+ * Fly's guest presets. An invalid combination is rejected at apply time.
47
+ */
48
+ export const guestSizingRule: LintRule = {
49
+ id: "FLY002",
50
+ severity: "error",
51
+ category: "correctness",
52
+ description: "A guest's cpu_kind, cpus and memory_mb must be a valid Fly combination",
53
+
54
+ check(context: LintContext): LintDiagnostic[] {
55
+ const { sourceFile } = context;
56
+ const diagnostics: LintDiagnostic[] = [];
57
+
58
+ function report(node: ts.Node, message: string): void {
59
+ const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
60
+ diagnostics.push({
61
+ file: sourceFile.fileName,
62
+ line: line + 1,
63
+ column: character + 1,
64
+ ruleId: "FLY002",
65
+ severity: "error",
66
+ message,
67
+ });
68
+ }
69
+
70
+ function visit(node: ts.Node): void {
71
+ // A guest is any object literal carrying a cpu_kind property (new MachineGuest({...})
72
+ // or an inline guest object).
73
+ if (ts.isObjectLiteralExpression(node)) {
74
+ const cpuKind = stringProp(node, "cpu_kind", sourceFile);
75
+ if (cpuKind !== undefined) {
76
+ const preset = GUEST_PRESETS[cpuKind];
77
+ if (!preset) {
78
+ report(node, `Invalid guest cpu_kind "${cpuKind}". Use "shared" or "performance".`);
79
+ } else {
80
+ const cpus = numberProp(node, "cpus", sourceFile);
81
+ const memoryMb = numberProp(node, "memory_mb", sourceFile);
82
+ if (cpus !== undefined && !preset.cpus.has(cpus)) {
83
+ report(node, `Invalid guest sizing: cpu_kind "${cpuKind}" does not allow ${cpus} cpus (valid: ${[...preset.cpus].join(", ")}).`);
84
+ } else if (cpus !== undefined && memoryMb !== undefined) {
85
+ const min = preset.minPerCpu * cpus;
86
+ const max = preset.maxPerCpu * cpus;
87
+ if (memoryMb % MEMORY_STEP_MB !== 0 || memoryMb < min || memoryMb > max) {
88
+ report(node, `Invalid guest sizing: ${cpuKind}/${cpus} cpu requires memory_mb between ${min} and ${max} in steps of ${MEMORY_STEP_MB}, got ${memoryMb}.`);
89
+ }
90
+ }
91
+ }
92
+ }
93
+ }
94
+ ts.forEachChild(node, visit);
95
+ }
96
+
97
+ visit(sourceFile);
98
+ return diagnostics;
99
+ },
100
+ };
@@ -0,0 +1,67 @@
1
+ import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
2
+ import * as ts from "typescript";
3
+
4
+ /**
5
+ * Property names that carry a credential. Kept conservative on purpose — an
6
+ * inline value under one of these keys is very likely a real secret.
7
+ */
8
+ const SECRET_KEY_PATTERN = /(?:^|[_-])(?:password|passwd|secret|token|api[_-]?key|apikey|access[_-]?key|secret[_-]?key|private[_-]?key|client[_-]?secret|credential|auth[_-]?token)(?:$|[_-])/i;
9
+
10
+ /**
11
+ * Values that are references or placeholders, not literal secrets:
12
+ * shell/interpolation references ($FOO, ${FOO}), and secret-manager style
13
+ * references. These are skipped so a `secrets` reference is never flagged.
14
+ */
15
+ const REFERENCE_VALUE_PATTERN = /^(?:\$\{?[A-Za-z0-9_]+\}?|(?:secret|ref|env|vault):\S+)$/;
16
+
17
+ /**
18
+ * FLY004: No secret literals in machine config
19
+ *
20
+ * Flags secret values written inline in config (e.g. an env value under a
21
+ * credential-looking key). Secrets belong in `secrets` (apply-only) or a
22
+ * reference. The heuristic is conservative: it fires only when the property
23
+ * name looks like a credential AND the value is a plain string literal that is
24
+ * not a reference/placeholder, so a `secrets` reference (an identifier or
25
+ * `$FOO` placeholder) is not flagged.
26
+ */
27
+ export const noSecretLiteralsRule: LintRule = {
28
+ id: "FLY004",
29
+ severity: "warning",
30
+ category: "security",
31
+ description: "Secret values must not be written inline — use secrets or a reference",
32
+
33
+ check(context: LintContext): LintDiagnostic[] {
34
+ const { sourceFile } = context;
35
+ const diagnostics: LintDiagnostic[] = [];
36
+
37
+ function visit(node: ts.Node): void {
38
+ if (ts.isPropertyAssignment(node)) {
39
+ const rawName = node.name.getText(sourceFile).replace(/^["']|["']$/g, "");
40
+ const init = node.initializer;
41
+ const literal =
42
+ ts.isStringLiteral(init) || ts.isNoSubstitutionTemplateLiteral(init) ? init.text : undefined;
43
+
44
+ if (
45
+ literal !== undefined &&
46
+ literal.length >= 4 &&
47
+ SECRET_KEY_PATTERN.test(rawName) &&
48
+ !REFERENCE_VALUE_PATTERN.test(literal.trim())
49
+ ) {
50
+ const { line, character } = sourceFile.getLineAndCharacterOfPosition(init.getStart(sourceFile));
51
+ diagnostics.push({
52
+ file: sourceFile.fileName,
53
+ line: line + 1,
54
+ column: character + 1,
55
+ ruleId: "FLY004",
56
+ severity: "warning",
57
+ message: `Possible inline secret under "${rawName}". Move the value to \`secrets\` (apply-only) or use a reference instead of an inline literal.`,
58
+ });
59
+ }
60
+ }
61
+ ts.forEachChild(node, visit);
62
+ }
63
+
64
+ visit(sourceFile);
65
+ return diagnostics;
66
+ },
67
+ };
@@ -0,0 +1,58 @@
1
+ import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
2
+ import * as ts from "typescript";
3
+
4
+ /**
5
+ * Known Fly.io region codes.
6
+ *
7
+ * Sourced from the representative region list mudflaps serves at
8
+ * `GET /v1/platform/regions` (mudflaps `internal/server/regions.go`), which
9
+ * mirrors fly-go's `GetRegions` wire shape. The generated Fly types (#737)
10
+ * type `region` as a plain `string`, so there is no generated enum to import;
11
+ * this static set is the closest stable list a unit test can check without a
12
+ * live platform. It is representative, not exhaustive — add codes as Fly adds
13
+ * regions.
14
+ */
15
+ const KNOWN_REGIONS = new Set<string>([
16
+ "ams", "atl", "bog", "bos", "cdg", "den", "dfw", "ewr", "fra", "gru",
17
+ "hkg", "iad", "jnb", "lax", "lhr", "mad", "mia", "nrt", "ord", "scl",
18
+ "sea", "sin", "sjc", "syd", "yyz",
19
+ ]);
20
+
21
+ /**
22
+ * FLY001: Valid Fly region
23
+ *
24
+ * A machine (or volume/IP) `region` must be a known Fly region code. An unknown
25
+ * region is rejected at apply time; catch it at build with a clearer message.
26
+ */
27
+ export const validRegionRule: LintRule = {
28
+ id: "FLY001",
29
+ severity: "error",
30
+ category: "correctness",
31
+ description: "A region must be a known Fly region code",
32
+
33
+ check(context: LintContext): LintDiagnostic[] {
34
+ const { sourceFile } = context;
35
+ const diagnostics: LintDiagnostic[] = [];
36
+
37
+ function visit(node: ts.Node): void {
38
+ if (ts.isPropertyAssignment(node) && node.name.getText(sourceFile) === "region") {
39
+ const init = node.initializer;
40
+ if (ts.isStringLiteral(init) && init.text.length > 0 && !KNOWN_REGIONS.has(init.text)) {
41
+ const { line, character } = sourceFile.getLineAndCharacterOfPosition(init.getStart(sourceFile));
42
+ diagnostics.push({
43
+ file: sourceFile.fileName,
44
+ line: line + 1,
45
+ column: character + 1,
46
+ ruleId: "FLY001",
47
+ severity: "error",
48
+ message: `Unknown Fly region "${init.text}". Use a known region code, e.g. iad, lhr, sjc, fra, syd.`,
49
+ });
50
+ }
51
+ }
52
+ ts.forEachChild(node, visit);
53
+ }
54
+
55
+ visit(sourceFile);
56
+ return diagnostics;
57
+ },
58
+ };
@@ -0,0 +1,55 @@
1
+ ---
2
+ skill: chant-fly-ops
3
+ description: Operate a live Fly deploy — wait on stuck machines, resolve lease conflicts, prune safely, and target a real org versus the emulator
4
+ user-invocable: true
5
+ ---
6
+
7
+ # Fly Operations Playbook
8
+
9
+ This skill covers running `flyApply` against a live app: what the wait loop does, how leases resolve conflicts, when prune is safe, and how to point the same code at a real org or the mudflaps emulator. For authoring and the first deploy, see `chant-fly`; for the individual resource types, see `chant-fly-patterns`.
10
+
11
+ ## Targeting real Fly or the emulator
12
+
13
+ The endpoint resolves in this order: an explicit `endpoint` arg, then `FLY_FLAPS_BASE_URL`, then the real-Fly default (`https://api.machines.dev`). The bearer token defaults to `FLY_API_TOKEN`.
14
+
15
+ | Target | How |
16
+ |--------|-----|
17
+ | Local mudflaps (offline, no account) | Leave `FLY_API_TOKEN` unset and point `FLY_FLAPS_BASE_URL` at the mudflaps host (the deploy Op does this for you against a local container) |
18
+ | Real Fly org | Set `FLY_API_TOKEN`, drop the local `FLY_FLAPS_BASE_URL` override |
19
+
20
+ The same plan applies to both. The only difference is the endpoint, so the loop you test offline is the loop you ship.
21
+
22
+ ## Waiting for a machine to start
23
+
24
+ After a create or update, `flyApply` polls `GET .../wait` until the machine reaches `started` at its new `instance_id` (its config version). flaps caps its own long-poll at 60 seconds and answers 408 when that expires, so the client re-polls until an overall deadline (300 seconds by default). A destroy waits for `state=destroyed` the same way; a reaped machine satisfies that wait.
25
+
26
+ If a machine never reaches `started`:
27
+
28
+ | Symptom | Likely cause | What to do |
29
+ |---------|--------------|------------|
30
+ | Wait keeps re-polling, machine stays in `created` or `starting` | Image pull or boot is slow, or the guest sizing is under-provisioned | Check the image reference and the `MachineGuest` values; watch the machine on the target org |
31
+ | Wait fails with a non-408 status | flaps rejected the machine (bad config the build check did not catch, or an org-side limit) | Read the error body; fix the config and re-apply |
32
+ | Wait times out at the deadline | The machine cannot reach `started` in time | Inspect the machine directly on the org, then re-apply once the cause is fixed |
33
+
34
+ ## Lease conflicts
35
+
36
+ Mutating an existing machine (update or destroy) is gated behind a Machines API lease. `flyApply` acquires a lease, echoes the nonce in the `fly-machine-lease-nonce` header on the mutation, and releases the lease afterward. A leaked lease expires on its own TTL, so release is best-effort.
37
+
38
+ Conflict handling is automatic: a 409 whose body mentions a lease is a stale or lost nonce, so the applier re-acquires a fresh lease and retries the mutation once. A 409 that is not lease-shaped (for example "app already exists") is not retried. If a mutation keeps failing on a lease conflict, another operator is holding the machine; wait for their lease to clear or coordinate before re-applying.
39
+
40
+ ## Prune, and when it is safe
41
+
42
+ Prune is off by default and destructive. It removes resources the plan no longer declares.
43
+
44
+ - Machines are owned-only: a machine is pruned only if it carries the `managed-by: chant` marker. A foreign machine in the same app is never touched, so it is safe to run `flyApply` with prune against an app that also holds machines you manage elsewhere.
45
+ - Volumes, IPs, certificates, and secrets are app-scoped, because they have no marker channel. Under a chant-managed app, anything the plan no longer declares is removed, including a resource of those types created out of band. Before enabling prune on such an app, confirm every volume, IP, certificate, and secret in it is chant-declared, and keep prune to a single chant-declared app.
46
+
47
+ Each prune logs the resource and endpoint it removed, so a prune run is auditable from the Op output.
48
+
49
+ ## Teardown
50
+
51
+ `flyDelete` is the inverse of `flyApply`: destroy the machines the plan declares (dependents first), then delete the apps. It is idempotent, so an already-absent machine or app is a no-op. The deploy Op's teardown phase uses this to tear the emulator's app down at the end of an offline loop.
52
+
53
+ ## Re-applying is safe
54
+
55
+ A re-apply of an unchanged stack is a no-op per resource: machines whose config is structurally equal to live are skipped, volumes and certificates that already exist are skipped, and an IP of an already-present family is skipped. Only apply-only secrets are always re-set, because flaps exposes no value to diff against.
@@ -0,0 +1,71 @@
1
+ ---
2
+ skill: chant-fly-patterns
3
+ description: Volumes and mounts, IP assignments, certificates, apply-only secrets, and the app-boundary ownership model for Fly
4
+ user-invocable: true
5
+ ---
6
+
7
+ # Fly Resource Patterns
8
+
9
+ Beyond the App and Machine covered in `chant-fly`, the lexicon models `Volume`, `IPAddress`, `Certificate`, and `Secret`. This skill covers how they apply and prune, and the ownership boundary that makes prune safe.
10
+
11
+ ## Volumes and mounts
12
+
13
+ `flyApply` applies volumes before machines, because a machine's `config.mounts[]` references a volume by name, so the volume must exist first. A volume is created if absent (idempotent by name); a re-apply of an existing volume is a no-op.
14
+
15
+ The FLY011 build check enforces the link statically: every machine mount must reference a `Volume` declared in the stack, checked across files. A mount that points at an undeclared volume fails `chant build` before anything reaches the API.
16
+
17
+ ```ts
18
+ import { App, Machine, MachineConfig, MachineGuest, Volume, Fly } from "@intentius/chant-lexicon-fly";
19
+
20
+ export const app = new App({ name: "my-app", org_slug: Fly.OrgSlug });
21
+
22
+ export const data = new Volume({ name: "data", region: "iad", size_gb: 10 });
23
+
24
+ export const web = new Machine({
25
+ name: "web",
26
+ region: "iad",
27
+ config: new MachineConfig({
28
+ image: "flyio/hellofly:latest",
29
+ guest: new MachineGuest({ cpu_kind: "shared", cpus: 1, memory_mb: 256 }),
30
+ mounts: [{ volume: "data", path: "/data" }],
31
+ }),
32
+ });
33
+ ```
34
+
35
+ ## IP assignments
36
+
37
+ An IP is assigned if the declared type is not already present, keyed by family (shared v4, dedicated v4, or v6). Because the address is server-allocated, a re-apply of the same declared type is a no-op rather than a second assignment.
38
+
39
+ ## Certificates
40
+
41
+ A certificate is created if absent, idempotent by hostname. A re-apply for a hostname that already has a certificate is a no-op.
42
+
43
+ ## Apply-only secrets
44
+
45
+ Secrets are apply-only. flaps returns only a digest for a secret, never the value, so there is nothing to read back for a diff. `flyApply` always POSTs a declared secret and excludes it from any drift comparison, so every apply re-sets it. Secret values may not be written inline in machine config (the FLY004 build check rejects that); declare them as a `Secret` or a reference.
46
+
47
+ ```ts
48
+ import { Secret } from "@intentius/chant-lexicon-fly";
49
+
50
+ // The value comes from the environment or a reference, not a literal in source.
51
+ export const dbUrl = new Secret({ name: "DATABASE_URL", value: process.env.DATABASE_URL! });
52
+ ```
53
+
54
+ ## The app-boundary ownership model
55
+
56
+ Ownership is asymmetric, by design:
57
+
58
+ - Machines carry `config.metadata`, so they get the primary marker `managed-by: chant`. Prune filters on it, so a foreign machine in the same app is never touched.
59
+ - Volumes, IPs, certificates, and secrets carry no arbitrary metadata, so they have no marker channel. Their ownership boundary is the app itself, the way a CloudFormation stack owns its resources: everything under a chant-managed app is treated as chant's, and prune for these types is app-scoped. Anything live that the plan no longer declares under that app is removed.
60
+
61
+ The limitation: because these four types have no marker, a volume, IP, certificate, or secret created out of band inside a chant-managed app is indistinguishable from a chant one and can be pruned. That is the price of app-boundary ownership. The safeguard is that an app is only ever chant-managed when it carries the marker through its machines. Do not enable prune on an app that mixes chant-declared and hand-created volumes, IPs, certificates, or secrets, and never widen app-scoped prune beyond a single chant-declared app.
62
+
63
+ ## Apply order
64
+
65
+ `flyApply` applies in dependency order and prunes last:
66
+
67
+ 1. Apps.
68
+ 2. Volumes (before machines, so mounts resolve).
69
+ 3. Machines (create or update, then wait for `started`; updates go through a lease).
70
+ 4. IPs, certificates, secrets (independent of machines).
71
+ 5. Prune, if enabled: machines owned-only by marker; volumes, IPs, certificates, and secrets app-scoped.
@@ -0,0 +1,118 @@
1
+ ---
2
+ skill: chant-temporal-sprites
3
+ description: Run an agent task in a Sprite as a chant Op — create, exec, checkpoint, restore, and destroy, with checkpoint-as-compensation
4
+ user-invocable: true
5
+ ---
6
+
7
+ # Run an Agent Task in a Sprite
8
+
9
+ [Sprites](https://sprites.dev) are stateful, checkpointable sandboxes. Unlike a resource lexicon, a Sprite has no desired state to reconcile: it is a runtime-orchestration primitive, the same category as `k3dUp` or `httpCheck`. So the sprite lifecycle lives in chant's Op and activity layer, not in a declarative resource type.
10
+
11
+ This is the direct-API, Op-driven way to drive a Sprite: a structured, replayable activity sequence that a chant Op can checkpoint and roll back. It sits alongside the Sprites SDKs and CLI rather than replacing them, and it is not an MCP wrapper.
12
+
13
+ ## The five activities
14
+
15
+ Each activity is a direct REST call over an injectable HTTP client, imported from `@intentius/chant-lexicon-fly` (Sprites are a Fly product, so they live in the fly lexicon alongside Machines):
16
+
17
+ | Activity | What it does |
18
+ |----------|--------------|
19
+ | `spriteCreate` | Create a sandbox. The caller-chosen `name` becomes the sprite `id` that every later activity keys on |
20
+ | `spriteExec` | Run a command inside the sprite. A non-zero exit throws, so the phase fails and any `onFailure` compensation runs |
21
+ | `spriteCheckpoint` | Snapshot the sprite under a caller-chosen `label` |
22
+ | `spriteRestore` | Rewind the sprite to a labeled checkpoint |
23
+ | `spriteDestroy` | Destroy the sprite (idempotent; an already-gone sprite is a no-op) |
24
+
25
+ The sprite `id` and the checkpoint `label` are static strings the Op author writes, so nothing has to be threaded from a prior phase's output.
26
+
27
+ ## The happy path
28
+
29
+ Compose the activities into an Op as phases:
30
+
31
+ ```ts
32
+ import { Op, phase } from "@intentius/chant-lexicon-temporal";
33
+ import { spriteCreate, spriteCheckpoint, spriteExec, spriteDestroy }
34
+ from "@intentius/chant-lexicon-fly";
35
+
36
+ export default Op({
37
+ name: "agent-task",
38
+ overview: "Create a sprite, checkpoint, run the task, verify, destroy",
39
+ taskQueue: "sprites",
40
+ phases: [
41
+ phase("Create", [spriteCreate({ name: "task-1", image: "sprites/base:latest" })]),
42
+ phase("Checkpoint", [spriteCheckpoint({ id: "task-1", label: "pre-run" })]),
43
+ phase("Run", [spriteExec({ id: "task-1", cmd: "echo hello > /work/output" })]),
44
+ phase("Verify", [spriteExec({ id: "task-1", cmd: "cat /work/output" })]),
45
+ phase("Destroy", [spriteDestroy({ id: "task-1" })]),
46
+ ],
47
+ });
48
+ ```
49
+
50
+ There is no `build` phase and no serialized plan: the activities run in sequence. Run it with `chant run agent-task`.
51
+
52
+ ## Checkpoint-as-compensation
53
+
54
+ The reason Sprites map onto chant Ops so well is rollback. A VM checkpoint is a fast transactional boundary. An Op checkpoints before a risky phase and, on failure, restores the labeled checkpoint instead of running an inverse action. The environment itself is the transaction, so there is nothing to unwind by hand.
55
+
56
+ Put the `spriteRestore` in the Op's `onFailure`, referencing the same label the `Checkpoint` phase wrote:
57
+
58
+ ```ts
59
+ import { Op, phase } from "@intentius/chant-lexicon-temporal";
60
+ import { spriteCreate, spriteCheckpoint, spriteExec, spriteDestroy, spriteRestore }
61
+ from "@intentius/chant-lexicon-fly";
62
+
63
+ export default Op({
64
+ name: "guarded-task",
65
+ overview: "Checkpoint, run a risky step, restore on failure",
66
+ taskQueue: "sprites",
67
+ phases: [
68
+ phase("Create", [spriteCreate({ name: "task-1" })]),
69
+ phase("Checkpoint", [spriteCheckpoint({ id: "task-1", label: "pre-run" })]),
70
+ phase("Run", [spriteExec({ id: "task-1", cmd: "./risky.sh" })]),
71
+ phase("Destroy", [spriteDestroy({ id: "task-1" })]),
72
+ ],
73
+ onFailure: [
74
+ phase("Restore", [spriteRestore({ id: "task-1", checkpoint: "pre-run" })]),
75
+ ],
76
+ });
77
+ ```
78
+
79
+ When the `Run` phase's command exits non-zero, `spriteExec` throws, the phase fails, and the Op-level `onFailure` `Restore` rewinds the sprite to its `pre-run` checkpoint.
80
+
81
+ ## Targeting the emulator or real Sprites
82
+
83
+ The activities resolve their endpoint in this order: an explicit `endpoint` arg, then `SPRITES_BASE_URL`, then the real Sprites base. The same Op targets an emulator or real Sprites with no code change. The default `fetch` client adds `Authorization: Bearer ${SPRITES_API_TOKEN}` when a token is set; the emulator ignores it.
84
+
85
+ ```bash
86
+ # Point at a self-hosted or in-process emulator.
87
+ export SPRITES_BASE_URL=http://127.0.0.1:9000
88
+ chant run agent-task
89
+ ```
90
+
91
+ ```bash
92
+ # Real Sprites: drop the override and set a token.
93
+ unset SPRITES_BASE_URL
94
+ export SPRITES_API_TOKEN=...
95
+ chant run agent-task
96
+ ```
97
+
98
+ The offline, Docker-free emulator that CI runs against is `createSpritesFake()` in this lexicon (`src/op/activities/sprites-fake.ts`); the activities and their tests live alongside it in `sprites.ts`. The local-emulator flow is the one to develop against.
99
+
100
+ The real Sprites REST surface is provisional (S6, tracked in #766): the endpoint constants may still move to match the official API. The activity input and output contracts (the `Args` and `Result` shapes shown above) are the stable interface the Ops and the emulator are written against, so build your Ops on those.
101
+
102
+ ## Beyond the five: filesystem, config, and keep-alive
103
+
104
+ The same lexicon ships more Sprite primitives, all imported from `@intentius/chant-lexicon-fly` and resolved by `loadActivities(["fly"])`:
105
+
106
+ | Family | Activities | Use |
107
+ |--------|-----------|-----|
108
+ | Filesystem (#848) | `spriteWriteFile` / `spriteReadFile` / `spriteListDir` / `spriteRemove` | stage an input file and read a result out without shelling `spriteExec` + `cat` |
109
+ | Config reconcile (#849) | `spriteApplyNetworkPolicy` / `spriteApplyServices` | reconcile a Sprite's egress allowlist and background services against typed config (validated before any HTTP; a whole-object replace for policy, create-or-update by name for services) |
110
+ | Keep-alive (#847) | `spriteTaskCreate` / `spriteTaskRefresh` / `spriteTaskRelease` | hold a Sprite active for a session so it will not pause; a session past the 1-hour task cap refreshes on an interval |
111
+
112
+ These are still runtime-orchestration primitives, not declarable resources — a Sprite has no desired-state create body to reconcile.
113
+
114
+ ## Where it fits
115
+
116
+ The runnable starter is [`examples/sprites-agent-task`](../../examples/sprites-agent-task), which ships both Ops above. Run `chant run agent-task` for the happy path and `chant run guarded-task` to watch the checkpoint-as-compensation rollback. `guarded-task` exits non-zero on purpose: the `Run` phase fails, the `onFailure` `Restore` runs, and the sprite is back at `pre-run`.
117
+
118
+ [`examples/sprites-managed-agent-worker`](../../examples/sprites-managed-agent-worker) composes the config and keep-alive families into one [Claude Managed Agents](https://docs.sprites.dev/integrations/claude-managed-agents/) session: create → egress policy → keep-alive task → env-contract file → runner-as-service → run → release → destroy, with an `onFailure` that frees the hold and tears the Sprite down.
@@ -0,0 +1,123 @@
1
+ ---
2
+ skill: chant-fly
3
+ description: Author, lint, and deploy Fly apps and machines from a chant project, applied straight to the Machines API
4
+ user-invocable: true
5
+ ---
6
+
7
+ # Deploy to Fly Operational Playbook
8
+
9
+ ## How chant and Fly relate
10
+
11
+ chant is a synthesis compiler: it compiles TypeScript in `src/` into a plan of Fly Machines API ("flaps") create requests, then reconciles that plan against a Fly org. Unlike the AWS or GCP lexicons, there is no external CLI to hand off to. `flyApply` speaks the Machines API directly, so the same code that builds the plan also applies it. There is no `flyctl` shell-out and no state file to store, lock, or keep in sync.
12
+
13
+ The source of truth is the TypeScript in `src/`. The serialized plan (a JSON object keyed by entity name, each value a `{ endpoint, method, body }` flaps request) is an intermediate artifact.
14
+
15
+ Your job as an agent:
16
+
17
+ - Use `chant build` for synthesis and lint (region, guest sizing, mounts, secret literals).
18
+ - Use `flyApply` (via the deploy Op, `chant run`) to reconcile the plan against the Machines API: create and update machines, wait each to `started`, and optionally prune what chant owns.
19
+
20
+ ## The endpoint switch
21
+
22
+ One environment variable decides where the same code applies:
23
+
24
+ - `FLY_FLAPS_BASE_URL` unset, no token: point it at a local [mudflaps](https://github.com/intentius/mudflaps) emulator (offline, no Fly account, no bill). This is the loop CI runs.
25
+ - `FLY_FLAPS_BASE_URL` set to a real Fly org endpoint, plus `FLY_API_TOKEN`: the same plan deploys for real.
26
+
27
+ Resolution order for the endpoint is: an explicit `endpoint` arg, then `FLY_FLAPS_BASE_URL`, then the real-Fly default (`https://api.machines.dev`). The bearer token defaults to `FLY_API_TOKEN`; mudflaps ignores it.
28
+
29
+ Start from the runnable [`examples/local-fly`](../../examples/local-fly) loop:
30
+
31
+ ```bash
32
+ cd examples/local-fly
33
+ chant run fly # boots mudflaps, applies an App + Machine, waits for started, tears down
34
+ ```
35
+
36
+ That Op runs the phases boot, build, apply, verify, and teardown against a local mudflaps container (Docker required). To target a real org, drop the local endpoint override and set `FLY_API_TOKEN`.
37
+
38
+ ## Author an App and a Machine
39
+
40
+ Import resource types from `@intentius/chant-lexicon-fly`. They are generated from Fly's Machines API OpenAPI spec, so `MachineConfig` is typed all the way down through guest, services, mounts, and checks.
41
+
42
+ ```ts
43
+ import { App, Machine, MachineConfig, MachineGuest, Fly } from "@intentius/chant-lexicon-fly";
44
+
45
+ export const app = new App({ name: "my-app", org_slug: Fly.OrgSlug });
46
+
47
+ export const web = new Machine({
48
+ name: "web",
49
+ region: "iad",
50
+ config: new MachineConfig({
51
+ image: "flyio/hellofly:latest",
52
+ guest: new MachineGuest({ cpu_kind: "shared", cpus: 1, memory_mb: 256 }),
53
+ }),
54
+ });
55
+ ```
56
+
57
+ A machine that names no app is bound to the stack's sole app at apply time. You do not stamp the ownership marker yourself: the serializer writes `managed-by: chant` into each machine's `config.metadata`, and the owned-only prune reads it back.
58
+
59
+ The full resource set is `App`, `Machine`, `Volume`, `IPAddress`, `Certificate`, and `Secret`. Volumes, mounts, IPs, certificates, and apply-only secrets are covered in `chant-fly-patterns`.
60
+
61
+ ## Build and lint
62
+
63
+ ```bash
64
+ chant build src/
65
+ ```
66
+
67
+ Build synthesizes the flaps plan and runs the lint rules before anything reaches the API:
68
+
69
+ | Rule | Catches |
70
+ |------|---------|
71
+ | FLY001 | `region` is not a real Fly region |
72
+ | FLY002 | Guest sizing (`cpu_kind` / `cpus` / `memory_mb`) is not a valid combination |
73
+ | FLY004 | A secret value written inline in machine config |
74
+ | FLY010 | A machine config with no `image` |
75
+ | FLY011 | A machine mount that references a `Volume` not declared in the stack (checked across files) |
76
+
77
+ Fix every reported violation before applying. Secret values belong in a `Secret` or a reference, never inline (FLY004).
78
+
79
+ ## Apply with flyApply
80
+
81
+ `flyApply` reads the serialized plan and applies it to flaps in dependency order: app, then volumes, then machines, then IPs, certificates, and secrets. Per machine it does a GET-then-create or update, then waits.
82
+
83
+ - Create or update: POST the machine, then poll `GET .../wait` until it reaches `started` at its new `instance_id`. flaps caps its own long-poll at 60 seconds and answers 408 on expiry, so the client re-polls until its deadline (default 300 seconds).
84
+ - No-op on no drift: a re-apply of an unchanged machine (config structurally equal to live) does nothing.
85
+ - Leases: mutating an existing machine goes through the Machines API lease protocol. `flyApply` acquires a lease, echoes the nonce in the `fly-machine-lease-nonce` header on the mutation, and re-acquires and retries once if the lease was lost. Concurrent operators stay out of each other's way.
86
+
87
+ ### Owned-only prune
88
+
89
+ Prune is off by default and destructive; turn it on to remove declared-then-removed resources.
90
+
91
+ - Machines prune owned-only: a machine is destroyed only if it carries the `managed-by: chant` marker and the plan no longer declares it. An unmarked (foreign) machine in the same app is never modified or deleted, so the applier is safe to point at an app that also holds resources you manage elsewhere.
92
+ - Volumes, IPs, certificates, and secrets have no metadata channel, so their ownership boundary is the app itself. See `chant-fly-patterns` for that app-boundary model before enabling prune on an app that mixes chant and non-chant resources.
93
+
94
+ ## The deploy Op
95
+
96
+ The lexicon ships `flyDeploy`, a composite Op that wraps the boot, build, apply, verify, and teardown phases so `chant run` drives the whole loop as modeled activities with no raw shell.
97
+
98
+ ```ts
99
+ // examples/local-fly/ops/fly.op.ts
100
+ import { flyDeploy } from "@intentius/chant-lexicon-fly";
101
+
102
+ export default flyDeploy({ app: "local-fly-demo" });
103
+ ```
104
+
105
+ `chant run fly` boots mudflaps, builds the plan, applies the App and Machine, waits for the machine to reach `started`, and tears the emulator down. To deploy the same Op to a real org, drop the local endpoint override and set `FLY_API_TOKEN`.
106
+
107
+ ## Teardown
108
+
109
+ `flyDelete` is the inverse of `flyApply`: it destroys the machines the plan declares (dependents first), then deletes the apps. It is idempotent, so an already-absent resource is a no-op.
110
+
111
+ ## Quick reference
112
+
113
+ | Command | Description |
114
+ |---------|-------------|
115
+ | `chant build src/` | Synthesize the flaps plan and run lint (FLY001/FLY002/FLY004/FLY010/FLY011) |
116
+ | `chant run fly` | Run the deploy Op (boot, build, apply, verify, teardown) |
117
+ | `FLY_FLAPS_BASE_URL=...` | Point the same code at mudflaps or a real Fly org |
118
+ | `FLY_API_TOKEN=...` | Bearer token for a real Fly org (mudflaps ignores it) |
119
+
120
+ ## Where to go next
121
+
122
+ - `chant-fly-patterns` covers volumes and mounts, IP assignments, certificates, apply-only secrets, and the app-boundary ownership model.
123
+ - `chant-fly-ops` covers operating a live app: waiting on stuck machines, lease conflicts, prune safety, and targeting a real org versus the emulator.
@@ -1 +1 @@
1
- {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH,OAAO,EAA4B,KAAK,cAAc,EAAE,MAAM,mCAAmC,CAAC;AAElG,YAAY,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,mCAAmC,CAAC;AAKvF;;GAEG;AACH,wBAAsB,QAAQ,CAAC,IAAI,CAAC,EAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,cAAc,CAAC,CAQpF"}
1
+ {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH,OAAO,EAA4B,KAAK,cAAc,EAAE,MAAM,mCAAmC,CAAC;AAElG,YAAY,EAAE,aAAa,EAAE,cAAc,EAAE,MAAM,mCAAmC,CAAC;AAgBvF;;GAEG;AACH,wBAAsB,QAAQ,CAAC,IAAI,CAAC,EAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,cAAc,CAAC,CAQpF"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-fly",
3
- "version": "0.33.0",
3
+ "version": "0.33.1",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src/",
@@ -60,7 +60,7 @@
60
60
  "@intentius/chant": "^0.33.0",
61
61
  "typescript": "^5.9.3"
62
62
  },
63
- "description": "Google Cloud lexicon for chant — declarative IaC in TypeScript",
63
+ "description": "Fly.io Machines lexicon for chant — declarative IaC in TypeScript",
64
64
  "license": "Apache-2.0",
65
65
  "repository": {
66
66
  "type": "git",
@@ -75,7 +75,7 @@
75
75
  "infrastructure-as-code",
76
76
  "iac",
77
77
  "typescript",
78
- "google-cloud",
78
+ "fly-io",
79
79
  "chant"
80
80
  ]
81
81
  }
@@ -1,6 +1,6 @@
1
- import { packagePipeline } from "@intentius/chant/codegen/package";
2
- import type { PackagePipelineConfig } from "@intentius/chant/codegen/package";
1
+ import { packagePipeline, collectSkills } from "@intentius/chant/codegen/package";
3
2
  import { generate } from "./generate";
3
+ import { flyPlugin } from "../plugin";
4
4
  import { readFileSync } from "fs";
5
5
  import { dirname, join } from "path";
6
6
  import { fileURLToPath } from "url";
@@ -9,8 +9,12 @@ import { fileURLToPath } from "url";
9
9
  * Package the fly lexicon for distribution.
10
10
  */
11
11
  export async function packageLexicon(options?: { verbose?: boolean; force?: boolean }) {
12
- const srcDir = dirname(fileURLToPath(import.meta.url));
13
- const pkgJson = JSON.parse(readFileSync(join(srcDir, "..", "..", "package.json"), "utf-8"));
12
+ // This file is src/codegen/package.ts — rules and skills are collected
13
+ // relative to src/, so srcDir must be the parent of this directory.
14
+ // Pointing it at src/codegen made collectRules glob a directory that does
15
+ // not exist, and the bundle shipped zero rules alongside zero skills.
16
+ const srcDir = dirname(dirname(fileURLToPath(import.meta.url)));
17
+ const pkgJson = JSON.parse(readFileSync(join(srcDir, "..", "package.json"), "utf-8"));
14
18
 
15
19
  const { spec, stats } = await packagePipeline({
16
20
  generate: (opts) => generate({ verbose: opts?.verbose, force: opts?.force }),
@@ -18,16 +22,17 @@ export async function packageLexicon(options?: { verbose?: boolean; force?: bool
18
22
  // (a hardcoded "0.0.1", never the real package version), so
19
23
  // `dist/manifest.json` shipped without a chantVersion at all. Matches
20
24
  // the shape every other lexicon's codegen/package.ts already uses.
21
- buildManifest: (genResult) => ({
25
+ buildManifest: (_genResult) => ({
22
26
  name: "fly",
23
27
  version: pkgJson.version ?? "0.0.0",
24
28
  chantVersion: ">=0.1.0",
25
29
  namespace: "Fly",
26
30
  }),
27
31
  srcDir,
28
- collectSkills: () => new Map(),
32
+ collectSkills: () => collectSkills(flyPlugin.skills?.() ?? []),
29
33
  });
30
34
 
31
- console.error(`Packaged ${stats.resources} resources, ${stats.ruleCount} rules`);
35
+ // Both callers (the plugin's package() and package-cli) print their own
36
+ // summary — don't double-report here.
32
37
  return { spec, stats };
33
38
  }
@@ -20,5 +20,7 @@ const force = process.argv.includes("--force");
20
20
  const { spec, stats } = await packageLexicon({ verbose, force });
21
21
  writeBundleSpec(spec, distDir);
22
22
 
23
- console.error(`Packaged ${stats.resources} resources, ${stats.ruleCount} rules`);
23
+ console.error(
24
+ `Packaged ${stats.resources} resources, ${stats.ruleCount} rules, ${stats.skillCount} skills`,
25
+ );
24
26
  console.error(`dist/ written to ${distDir}`);
package/src/validate.ts CHANGED
@@ -11,8 +11,19 @@ import { validateLexiconArtifacts, type ValidateResult } from "@intentius/chant/
11
11
 
12
12
  export type { ValidateCheck, ValidateResult } from "@intentius/chant/codegen/validate";
13
13
 
14
- // TODO: Add names of required entities for your lexicon
15
- const REQUIRED_NAMES: string[] = [];
14
+ /**
15
+ * The curated top-level Machines API resources (#741). A regeneration that
16
+ * loses one — an upstream rename, a spec fetch that returned a partial
17
+ * document — must fail validation rather than ship a lexicon missing a kind.
18
+ */
19
+ const REQUIRED_NAMES: string[] = [
20
+ "App",
21
+ "Machine",
22
+ "Volume",
23
+ "IPAddress",
24
+ "Certificate",
25
+ "Secret",
26
+ ];
16
27
 
17
28
  /**
18
29
  * Validate the generated lexicon-fly artifacts.