@intentius/chant-lexicon-fly 0.124.31 → 0.124.32

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 (83) hide show
  1. package/dist/codegen/docs-cli.js +6 -0
  2. package/dist/codegen/docs.js +143 -0
  3. package/dist/codegen/generate-cli.js +26 -0
  4. package/dist/codegen/generate-lexicon.js +38 -0
  5. package/dist/codegen/generate-typescript.js +45 -0
  6. package/dist/codegen/generate.js +82 -0
  7. package/dist/codegen/naming.js +33 -0
  8. package/dist/codegen/package.js +35 -0
  9. package/dist/components/capability-plugin.js +26 -0
  10. package/dist/components/fly-release.js +335 -0
  11. package/dist/components/index.js +10 -0
  12. package/dist/components/run-agent.js +133 -0
  13. package/dist/composites/catalog.js +176 -0
  14. package/dist/composites/fly-deploy.js +91 -0
  15. package/dist/composites/fly-otel-collector.js +66 -0
  16. package/dist/composites/fly-site.js +80 -0
  17. package/dist/coverage.js +29 -0
  18. package/dist/describe-resources.js +443 -0
  19. package/dist/detect.js +63 -0
  20. package/dist/export-resources.js +81 -0
  21. package/dist/generated/index.js +44 -0
  22. package/dist/generated/runtime.js +4 -0
  23. package/dist/import/generator.js +208 -0
  24. package/dist/import/live-export.js +81 -0
  25. package/dist/import/parser.js +157 -0
  26. package/dist/index.js +40 -0
  27. package/dist/integrity.json +3 -3
  28. package/dist/lint/audit-catalog.js +28 -0
  29. package/dist/lint/audit-lineage.js +5 -0
  30. package/dist/lint/post-synth/fly-helpers.js +24 -0
  31. package/dist/lint/post-synth/fly010-machine-requires-image.js +33 -0
  32. package/dist/lint/post-synth/fly011-mount-references-declared-volume.js +58 -0
  33. package/dist/lint/post-synth/index.js +6 -0
  34. package/dist/lint/rules/guest-sizing.js +92 -0
  35. package/dist/lint/rules/index.js +12 -0
  36. package/dist/lint/rules/no-secret-literals.js +58 -0
  37. package/dist/lint/rules/valid-region.js +52 -0
  38. package/dist/lsp/completions.js +12 -0
  39. package/dist/lsp/hover.js +12 -0
  40. package/dist/manifest.json +1 -1
  41. package/dist/mcp/context-tools.js +113 -0
  42. package/dist/okf/types/Machine.md +1 -1
  43. package/dist/op/activities/box-services.js +88 -0
  44. package/dist/op/activities/emulator-images.js +32 -0
  45. package/dist/op/activities/flaps.js +34 -0
  46. package/dist/op/activities/fly-apply.js +911 -0
  47. package/dist/op/activities/fly-release-step.js +21 -0
  48. package/dist/op/activities/fly-rollback-step.js +22 -0
  49. package/dist/op/activities/index.js +53 -0
  50. package/dist/op/activities/machine-release.js +236 -0
  51. package/dist/op/activities/machines-contract.js +81 -0
  52. package/dist/op/activities/machines-fake.js +203 -0
  53. package/dist/op/activities/machines-local-cli.js +85 -0
  54. package/dist/op/activities/machines-local.js +395 -0
  55. package/dist/op/activities/progress.js +10 -0
  56. package/dist/op/activities/sprite-config.js +409 -0
  57. package/dist/op/activities/sprite-fs.js +131 -0
  58. package/dist/op/activities/sprite-service-converge.js +224 -0
  59. package/dist/op/activities/sprite-services.js +174 -0
  60. package/dist/op/activities/sprite-tasks.js +57 -0
  61. package/dist/op/activities/sprites-contract.js +87 -0
  62. package/dist/op/activities/sprites-emulator.js +72 -0
  63. package/dist/op/activities/sprites-fake.js +547 -0
  64. package/dist/op/activities/sprites.js +515 -0
  65. package/dist/op/activity-contracts.js +82 -0
  66. package/dist/op/builders.js +157 -0
  67. package/dist/ownership.js +29 -0
  68. package/dist/package-cli.js +20 -0
  69. package/dist/plugin.js +298 -0
  70. package/dist/pseudo.js +26 -0
  71. package/dist/reference-catalog.js +37 -0
  72. package/dist/release-metadata.js +59 -0
  73. package/dist/release-store.js +46 -0
  74. package/dist/serializer.js +299 -0
  75. package/dist/spec/fetch.js +32 -0
  76. package/dist/spec/parse.js +260 -0
  77. package/dist/teardown.js +194 -0
  78. package/dist/types/index.d.ts +33 -2
  79. package/dist/validate-cli.js +4 -0
  80. package/dist/validate.js +33 -0
  81. package/package.json +11 -11
  82. package/src/codegen/package.ts +2 -2
  83. package/src/generated/index.d.ts +33 -2
@@ -0,0 +1,409 @@
1
+ /**
2
+ * Sprite config reconcile activities (#849) — the two pieces of genuine
3
+ * desired-state a Sprite carries: its outbound network policy and its background
4
+ * services. Unlike the sprite itself (an Op primitive with no reconcilable
5
+ * create body), these are set after create and persist across cold boots, so
6
+ * chant reconciles typed config against the live Sprite — the `flyApply`
7
+ * plan→CRUD pattern scoped to one Sprite, not a declarable resource.
8
+ *
9
+ * Both use the JSON `SpritesHttp` client from `sprites.ts`; endpoint + bearer
10
+ * resolution is shared. Validation is pure and runs before any HTTP (invalid
11
+ * rules / a `needs` cycle throw up front), so a bad config fails the step
12
+ * cleanly rather than half-applying.
13
+ *
14
+ * Scope (v1): network policy is a whole-object replace (converges by
15
+ * construction); services reconcile is additive + update (create-or-update each
16
+ * desired service, optionally start). Owned-only prune of stale services is out
17
+ * of scope — the documented Services REST surface exposes no delete.
18
+ *
19
+ * `spriteApplyServices` with no `id` runs inside the sprite instead (#2880):
20
+ * it applies the box block's services (`box: true`) through `sprite-env
21
+ * services`, which has no update, so a changed service is deleted and created.
22
+ * It deletes nothing the box block does not declare.
23
+ */
24
+ import { execFile } from "node:child_process";
25
+ import { promisify } from "node:util";
26
+ import { serviceCommandArgv, inStartOrder, boxServices } from "./box-services.js";
27
+ import { findSpriteEnv } from "./sprite-service-converge.js";
28
+ import { resolveSpritesEndpoint, defaultSpritesHttp } from "./sprites.js";
29
+ import { logProgress } from "./progress.js";
30
+ const execFileAsync = promisify(execFile);
31
+ function safeJson(text) {
32
+ try {
33
+ return JSON.parse(text);
34
+ }
35
+ catch {
36
+ return undefined;
37
+ }
38
+ }
39
+ /**
40
+ * Validate a ruleset: every rule needs a non-empty `domain` and an `allow`/`deny`
41
+ * `action`. Pure; throws on the first offender.
42
+ */
43
+ export function validateNetworkRules(rules) {
44
+ rules.forEach((r, i) => {
45
+ if (!r || typeof r.domain !== "string" || r.domain.trim() === "") {
46
+ throw new Error(`network rule ${i}: missing domain`);
47
+ }
48
+ if (r.action !== "allow" && r.action !== "deny") {
49
+ throw new Error(`network rule ${i} (${r.domain}): action must be "allow" or "deny", got ${JSON.stringify(r.action)}`);
50
+ }
51
+ });
52
+ }
53
+ /** Order-sensitive equality of two rulesets (position is significant). Pure. */
54
+ export function networkRulesEqual(a, b) {
55
+ if (a.length !== b.length)
56
+ return false;
57
+ return a.every((r, i) => r.domain === b[i].domain && r.action === b[i].action);
58
+ }
59
+ const policyUrl = (base, id) => `${base}/v1/sprites/${encodeURIComponent(id)}/policy/network`;
60
+ /**
61
+ * Reconcile a Sprite's outbound network policy. GET the live ruleset, and POST
62
+ * the desired set only when it differs (`GET`/`POST /policy/network`). Returns
63
+ * whether a change was applied.
64
+ */
65
+ export async function spriteApplyNetworkPolicy(args, signal, http = defaultSpritesHttp(args.token)) {
66
+ validateNetworkRules(args.rules);
67
+ const base = resolveSpritesEndpoint(args);
68
+ const url = policyUrl(base, args.id);
69
+ const cur = await http("GET", url, undefined, undefined, signal);
70
+ if (cur.status >= 300)
71
+ throw new Error(`sprite ${args.id} get policy failed (${cur.status}): ${cur.text}`);
72
+ const live = safeJson(cur.text)?.rules ?? [];
73
+ if (networkRulesEqual(live, args.rules)) {
74
+ logProgress(`policy: sprite/${args.id} already converged (${args.rules.length} rules)`);
75
+ return { changed: false };
76
+ }
77
+ const res = await http("POST", url, { rules: args.rules }, undefined, signal);
78
+ if (res.status >= 300)
79
+ throw new Error(`sprite ${args.id} set policy failed (${res.status}): ${res.text}`);
80
+ logProgress(`policy: sprite/${args.id} applied ${args.rules.length} rules (${base})`);
81
+ return { changed: true };
82
+ }
83
+ /**
84
+ * Validate a service set: names are unique, every `needs` target exists, and the
85
+ * dependency graph is acyclic. Pure; throws on the first violation. Returns the
86
+ * names in a valid start order (dependencies first).
87
+ */
88
+ export function validateServices(services) {
89
+ const byName = new Map();
90
+ for (const s of services) {
91
+ if (!s.name)
92
+ throw new Error("service: missing name");
93
+ if (byName.has(s.name))
94
+ throw new Error(`service ${s.name}: duplicate name`);
95
+ byName.set(s.name, s);
96
+ }
97
+ for (const s of services) {
98
+ for (const dep of s.needs ?? []) {
99
+ if (!byName.has(dep))
100
+ throw new Error(`service ${s.name}: needs "${dep}" which is not defined`);
101
+ }
102
+ }
103
+ // Topological order via DFS; a back-edge is a cycle.
104
+ const order = [];
105
+ const state = new Map();
106
+ const visit = (name, trail) => {
107
+ const st = state.get(name);
108
+ if (st === "done")
109
+ return;
110
+ if (st === "visiting")
111
+ throw new Error(`service dependency cycle: ${[...trail, name].join(" -> ")}`);
112
+ state.set(name, "visiting");
113
+ for (const dep of byName.get(name).needs ?? [])
114
+ visit(dep, [...trail, name]);
115
+ state.set(name, "done");
116
+ order.push(name);
117
+ };
118
+ for (const s of services)
119
+ visit(s.name, []);
120
+ return order;
121
+ }
122
+ /** Comparable service config (the fields the reconcile diffs on). Pure. */
123
+ export function serviceConfigEqual(a, b) {
124
+ const norm = (s) => JSON.stringify({
125
+ cmd: s.cmd ?? "",
126
+ args: s.args ?? [],
127
+ env: s.env ?? {},
128
+ dir: s.dir ?? "",
129
+ needs: s.needs ?? [],
130
+ http_port: s.http_port ?? null,
131
+ });
132
+ return norm(a) === norm(b);
133
+ }
134
+ const servicesUrl = (base, id) => `${base}/v1/sprites/${encodeURIComponent(id)}/services`;
135
+ const serviceUrl = (base, id, svc) => `${servicesUrl(base, id)}/${encodeURIComponent(svc)}`;
136
+ function serviceBody(s) {
137
+ return {
138
+ cmd: s.cmd,
139
+ ...(s.args ? { args: s.args } : {}),
140
+ ...(s.env ? { env: s.env } : {}),
141
+ ...(s.dir ? { dir: s.dir } : {}),
142
+ ...(s.needs ? { needs: s.needs } : {}),
143
+ ...(s.http_port !== undefined ? { http_port: s.http_port } : {}),
144
+ };
145
+ }
146
+ /**
147
+ * Reconcile a Sprite's background services (additive + update). Lists the live
148
+ * services, then `PUT`s each desired service that is new or changed
149
+ * (create-or-update by name); when `start` is set, `POST .../start`s them in
150
+ * dependency order. Converged services are skipped. Owned-only prune is out of
151
+ * scope (no delete in the Services REST surface).
152
+ */
153
+ export async function spriteApplyServices(args, signal, http) {
154
+ if (!args.id)
155
+ return applyThroughSpriteEnv(args, signal);
156
+ if (args.box || args.only || args.restart) {
157
+ throw new Error("spriteApplyServices: box, only and restart apply a box's services through sprite-env inside the sprite; drop the id to use them");
158
+ }
159
+ if (!args.services)
160
+ throw new Error("spriteApplyServices: an id needs its services");
161
+ return applyThroughApi({ ...args, id: args.id, services: args.services }, signal, http ?? defaultSpritesHttp(args.token));
162
+ }
163
+ async function applyThroughApi(args, signal, http) {
164
+ const startOrder = validateServices(args.services);
165
+ const base = resolveSpritesEndpoint(args);
166
+ const byName = new Map(args.services.map((s) => [s.name, s]));
167
+ // Live services, keyed by name, for the create-vs-update diff.
168
+ const listRes = await http("GET", servicesUrl(base, args.id), undefined, undefined, signal);
169
+ if (listRes.status >= 300)
170
+ throw new Error(`sprite ${args.id} list services failed (${listRes.status}): ${listRes.text}`);
171
+ const liveList = safeJson(listRes.text);
172
+ const live = new Map();
173
+ if (Array.isArray(liveList)) {
174
+ for (const s of liveList) {
175
+ if (s.name)
176
+ live.set(s.name, s);
177
+ }
178
+ }
179
+ const applied = [];
180
+ for (const s of args.services) {
181
+ const cur = live.get(s.name);
182
+ if (cur && serviceConfigEqual(cur, s))
183
+ continue; // already converged
184
+ const res = await http("PUT", serviceUrl(base, args.id, s.name), serviceBody(s), undefined, signal);
185
+ if (res.status >= 300)
186
+ throw new Error(`sprite ${args.id} apply service ${s.name} failed (${res.status}): ${res.text}`);
187
+ applied.push(s.name);
188
+ }
189
+ logProgress(`services: sprite/${args.id} applied ${applied.length}/${args.services.length} (${base})`);
190
+ const started = [];
191
+ if (args.start) {
192
+ for (const name of startOrder) {
193
+ if (!byName.has(name))
194
+ continue;
195
+ const res = await http("POST", `${serviceUrl(base, args.id, name)}/start`, undefined, undefined, signal);
196
+ if (res.status >= 300)
197
+ throw new Error(`sprite ${args.id} start service ${name} failed (${res.status}): ${res.text}`);
198
+ started.push(name);
199
+ }
200
+ logProgress(`services: sprite/${args.id} started ${started.length} in dependency order`);
201
+ }
202
+ return { applied, started };
203
+ }
204
+ /**
205
+ * `sprite-env services list` with each service's definition. A sprite's
206
+ * sprite-env prints JSON (`[{ name, cmd, args, needs, http_port, state: {
207
+ * status } }]`); the studio kit's stand-in has printed a table of name,
208
+ * state, needs (`-` for none), http port (`-`) and cmd, tab-separated. A
209
+ * table with fewer columns gives the name and state only. Pure.
210
+ */
211
+ export function parseServiceDefinitions(text) {
212
+ const trimmed = text.trim();
213
+ if (!trimmed)
214
+ return [];
215
+ if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
216
+ const parsed = JSON.parse(trimmed);
217
+ const list = Array.isArray(parsed) ? parsed : (parsed.services ?? []);
218
+ const out = [];
219
+ for (const s of list) {
220
+ if (typeof s?.name !== "string")
221
+ continue;
222
+ const state = s.state;
223
+ const status = typeof state?.status === "string" ? state.status : typeof s.status === "string" ? s.status : null;
224
+ const args = Array.isArray(s.args) ? s.args.map(String) : [];
225
+ out.push({
226
+ name: s.name,
227
+ status,
228
+ ...(typeof s.cmd === "string" ? { cmd: args.length > 0 ? [s.cmd, ...args].join(" ") : s.cmd } : {}),
229
+ ...(Array.isArray(s.needs) ? { needs: s.needs.map(String) } : s.needs === null ? { needs: [] } : {}),
230
+ ...("http_port" in s ? { httpPort: typeof s.http_port === "number" && s.http_port > 0 ? s.http_port : null } : {}),
231
+ });
232
+ }
233
+ return out;
234
+ }
235
+ const out = [];
236
+ for (const line of trimmed.split("\n")) {
237
+ const cols = line.split("\t");
238
+ const name = cols[0]?.trim();
239
+ if (!name)
240
+ continue;
241
+ const listed = { name, status: cols[1]?.trim() || null };
242
+ if (cols.length >= 5) {
243
+ const needs = cols[2].trim();
244
+ const port = cols[3].trim();
245
+ listed.needs = needs === "-" || needs === "" ? [] : needs.split(",");
246
+ listed.httpPort = port === "-" || port === "" ? null : Number(port);
247
+ listed.cmd = cols.slice(4).join("\t");
248
+ }
249
+ out.push(listed);
250
+ }
251
+ return out;
252
+ }
253
+ /** Whether a listed service differs from the declared one in `cmd`, `needs` or `httpPort`. A field the listing does not carry is not compared. Pure. */
254
+ export function listedServiceDiffers(listed, declared, cmd) {
255
+ if (listed.cmd !== undefined && listed.cmd !== cmd)
256
+ return `cmd ${JSON.stringify(listed.cmd)} is now ${JSON.stringify(cmd)}`;
257
+ if (listed.needs !== undefined) {
258
+ const a = [...listed.needs].sort().join(",");
259
+ const b = [...declared.needs].sort().join(",");
260
+ if (a !== b)
261
+ return `needs [${a}] is now [${b}]`;
262
+ }
263
+ if (listed.httpPort !== undefined && (listed.httpPort ?? null) !== declared.httpPort)
264
+ return `httpPort ${listed.httpPort ?? "none"} is now ${declared.httpPort ?? "none"}`;
265
+ return null;
266
+ }
267
+ /**
268
+ * The arguments of `sprite-env services create` for a declared service.
269
+ * `argv` is the command from {@link serviceCommandArgv}: its first word is
270
+ * `--cmd`, the rest `--args` (comma-separated), as sprite-env and upstream
271
+ * Sprites take them; neither runs `--cmd` through a shell. A string is split
272
+ * on whitespace. With a `duration`, sprite-env streams the service's output
273
+ * for that long and fails the create if the service exits in it, so
274
+ * `--no-stream` (which returns as soon as the service is defined) is passed
275
+ * only without one. Pure.
276
+ */
277
+ export function spriteEnvCreateArgs(s, argv) {
278
+ const [cmd, ...rest] = typeof argv === "string" ? argv.trim().split(/\s+/) : argv;
279
+ return [
280
+ "services",
281
+ "create",
282
+ s.name,
283
+ "--cmd",
284
+ cmd,
285
+ ...(rest.length > 0 ? ["--args", rest.join(",")] : []),
286
+ ...(s.needs.length > 0 ? ["--needs", s.needs.join(",")] : []),
287
+ ...(s.httpPort !== null ? ["--http-port", String(s.httpPort)] : []),
288
+ ...(s.duration !== null ? ["--duration", s.duration] : ["--no-stream"]),
289
+ ];
290
+ }
291
+ /** The states a service is in once started: `start` leaves it be. */
292
+ const UP = new Set(["running", "starting"]);
293
+ /**
294
+ * Apply the box block's services through sprite-env, in dependency order:
295
+ * create a declared service the supervisor doesn't have, delete and create
296
+ * one whose `cmd`, `needs` or `httpPort` differ from what the list reports,
297
+ * and leave a converged one running (restart it with `restart`). Then, with
298
+ * `start`, start each applied service that is not running.
299
+ */
300
+ async function applyThroughSpriteEnv(args, signal) {
301
+ if (!args.box)
302
+ throw new Error("spriteApplyServices: without an id it applies the box block's services through sprite-env; pass box: true (or an id and services for the Sprites API)");
303
+ if (args.services)
304
+ throw new Error("spriteApplyServices: box: true reads the services from the box block, so it takes no services beside it");
305
+ const declared = await boxServices();
306
+ const names = new Set(declared.map((s) => s.name));
307
+ const unknown = (args.only ?? []).filter((n) => !names.has(n));
308
+ if (unknown.length > 0) {
309
+ throw new Error(`spriteApplyServices: only names ${unknown.join(", ")}, which the box block does not declare; declared: ${[...names].join(", ") || "none"}`);
310
+ }
311
+ const only = args.only ? new Set(args.only) : null;
312
+ const targets = inStartOrder(declared).filter((s) => (only ? only.has(s.name) : !s.optional));
313
+ // Expand every command before changing anything, so a missing variable fails the step with nothing half-applied.
314
+ const commands = new Map(targets.map((s) => [s.name, serviceCommandArgv(s.name, s.cmd)]));
315
+ const bin = findSpriteEnv(args.spriteEnv);
316
+ if (!bin)
317
+ throw new Error("spriteApplyServices: no sprite-env on PATH or in /.sprite/bin, and no sprite id to reach the Sprites API with");
318
+ const spriteEnv = async (argv) => {
319
+ try {
320
+ return (await execFileAsync(bin, argv, { signal, timeout: 120000, maxBuffer: 16 * 1024 * 1024 })).stdout;
321
+ }
322
+ catch (err) {
323
+ const e = err;
324
+ throw new Error(`sprite-env ${argv.slice(0, 3).join(" ")} failed: ${(e.stderr || e.message || String(err)).trim()}`);
325
+ }
326
+ };
327
+ const live = new Map(parseServiceDefinitions(await spriteEnv(["services", "list"])).map((s) => [s.name, s]));
328
+ // A full apply leaves the supervisor with the declared services only: one
329
+ // the block no longer declares is deleted, before anything is defined, so
330
+ // the HTTP port or a name it held is free. Dependants go before what they need.
331
+ const services = [];
332
+ if (!only && args.prune !== false) {
333
+ const undeclared = new Set([...live.keys()].filter((n) => !names.has(n)));
334
+ while (undeclared.size > 0) {
335
+ const needed = new Set([...undeclared].flatMap((n) => live.get(n)?.needs ?? []));
336
+ const next = [...undeclared].sort().find((n) => !needed.has(n)) ?? [...undeclared].sort()[0];
337
+ await spriteEnv(["services", "delete", next]);
338
+ logProgress(`services: deleted ${next}, which the box block does not declare`);
339
+ services.push({ name: next, action: "deleted" });
340
+ undeclared.delete(next);
341
+ live.delete(next);
342
+ }
343
+ }
344
+ // The supervisor gives one service the HTTP port and refuses a second holder
345
+ // (409) while the first still has it. So a service whose listed port is not
346
+ // the declared one is replaced first, before any service is defined with a
347
+ // port: when the port moves from one service to another, the old holder has
348
+ // let go of it by the time the new one asks. One whose declared needs are not
349
+ // all defined yet can't be created early and waits for its turn below.
350
+ const released = new Set();
351
+ for (const s of targets) {
352
+ const cur = live.get(s.name);
353
+ if (!cur || cur.httpPort === undefined || cur.httpPort === null || cur.httpPort === s.httpPort)
354
+ continue;
355
+ if (!s.needs.every((n) => live.has(n)))
356
+ continue;
357
+ const argv = commands.get(s.name);
358
+ const why = listedServiceDiffers(cur, s, argv.join(" "));
359
+ await spriteEnv(["services", "delete", s.name]);
360
+ await spriteEnv(spriteEnvCreateArgs(s, argv));
361
+ logProgress(`services: replaced ${s.name} (${why}) first, to free the HTTP port`);
362
+ released.add(s.name);
363
+ }
364
+ for (const s of targets) {
365
+ if (released.has(s.name)) {
366
+ services.push({ name: s.name, action: "replaced" });
367
+ continue;
368
+ }
369
+ const argv = commands.get(s.name);
370
+ const cmd = argv.join(" ");
371
+ const cur = live.get(s.name);
372
+ if (!cur) {
373
+ await spriteEnv(spriteEnvCreateArgs(s, argv));
374
+ logProgress(`services: created ${s.name}`);
375
+ services.push({ name: s.name, action: "created" });
376
+ continue;
377
+ }
378
+ const why = listedServiceDiffers(cur, s, cmd);
379
+ if (why) {
380
+ await spriteEnv(["services", "delete", s.name]);
381
+ await spriteEnv(spriteEnvCreateArgs(s, argv));
382
+ logProgress(`services: replaced ${s.name} (${why})`);
383
+ services.push({ name: s.name, action: "replaced" });
384
+ continue;
385
+ }
386
+ if (args.restart) {
387
+ await spriteEnv(["services", "restart", s.name]);
388
+ logProgress(`services: restarted ${s.name}`);
389
+ services.push({ name: s.name, action: "restarted" });
390
+ continue;
391
+ }
392
+ services.push({ name: s.name, action: "left" });
393
+ }
394
+ const started = [];
395
+ if (args.start) {
396
+ for (const entry of services) {
397
+ if (entry.action !== "left" || UP.has(live.get(entry.name)?.status ?? ""))
398
+ continue;
399
+ await spriteEnv(["services", "start", entry.name]);
400
+ logProgress(`services: started ${entry.name}`);
401
+ entry.action = "started";
402
+ started.push(entry.name);
403
+ }
404
+ }
405
+ const applied = services.filter((e) => e.action === "created" || e.action === "replaced").map((e) => e.name);
406
+ const deleted = services.filter((e) => e.action === "deleted").length;
407
+ logProgress(`services: applied ${applied.length}/${targets.length} through sprite-env, started ${started.length}${deleted > 0 ? `, deleted ${deleted}` : ""}`);
408
+ return { applied, started, services };
409
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Sprite filesystem activities (#848) — imperative file-I/O primitives over the
3
+ * Sprites filesystem API (`/v1/sprites/{id}/fs/*`). Same category as the
4
+ * lifecycle activities in `sprites.ts`: runtime-orchestration primitives, no
5
+ * desired state. They let an Op stage an input file into a sprite and read a
6
+ * result out without shelling it through `spriteExec` + `cat`/`tee`.
7
+ *
8
+ * read/write move raw file bytes in the body (not JSON), so these use a small
9
+ * raw HTTP client (`SpritesRawHttp`) rather than the JSON `SpritesHttp` the
10
+ * lifecycle activities use; path/mode/mkdir/recursive ride as query params.
11
+ * Endpoint + bearer resolution go through `sprites.ts`'s shared resolvers
12
+ * (`resolveSpritesEndpoint`/`resolveSpritesToken`) — an explicit `endpoint` /
13
+ * `token` wins, then `SPRITES_BASE_URL`/`SPRITES_API_URL`, then
14
+ * `SPRITES_API_TOKEN`/its `SPRITE_TOKEN` alias (#2711, #2765: this module
15
+ * used to read `SPRITES_API_TOKEN` directly, missing the alias the other
16
+ * Sprites activities honor).
17
+ */
18
+ import { resolveSpritesEndpoint, resolveSpritesToken } from "./sprites.js";
19
+ import { logProgress } from "./progress.js";
20
+ function safeJson(text) {
21
+ try {
22
+ return JSON.parse(text);
23
+ }
24
+ catch {
25
+ return undefined;
26
+ }
27
+ }
28
+ // ── Pure URL builders (unit-testable) ──────────────────────────────────────────
29
+ /**
30
+ * Build a `/v1/sprites/{id}/fs/{op}?...` URL. `params` values that are undefined
31
+ * or empty are dropped so the query only carries what the caller set. Pure.
32
+ */
33
+ export function spriteFsUrl(base, id, op, params) {
34
+ const q = new URLSearchParams();
35
+ for (const [k, v] of Object.entries(params)) {
36
+ if (v === undefined || v === "")
37
+ continue;
38
+ q.set(k, typeof v === "boolean" ? String(v) : v);
39
+ }
40
+ const qs = q.toString();
41
+ return `${base}/v1/sprites/${encodeURIComponent(id)}/fs/${op}${qs ? `?${qs}` : ""}`;
42
+ }
43
+ /**
44
+ * Default `fetch`-based raw client. Sends the body as-is with an
45
+ * `application/octet-stream` content-type and `Authorization: Bearer <token>`
46
+ * when a token is set (real Sprites); the fake ignores the token. The token
47
+ * resolves through `resolveSpritesToken` at call time — `SPRITES_API_TOKEN`,
48
+ * then its `SPRITE_TOKEN` alias (#2711, #2765) — the same resolver
49
+ * `sprites.ts`'s `defaultSpritesHttp` uses, so every fs activity
50
+ * (`spriteWriteFile`, `spriteReadFile`, `spriteListDir`, `spriteRemove`)
51
+ * authenticates the same way the lifecycle activities do. `fetchImpl` is
52
+ * injectable.
53
+ */
54
+ export function defaultSpritesRawHttp(token, fetchImpl = fetch) {
55
+ return async (method, url, body, headers, signal) => {
56
+ const h = { ...headers };
57
+ if (body !== undefined)
58
+ h["content-type"] = "application/octet-stream";
59
+ const tok = resolveSpritesToken(token);
60
+ if (tok)
61
+ h["authorization"] = `Bearer ${tok}`;
62
+ const res = await fetchImpl(url, {
63
+ method,
64
+ headers: Object.keys(h).length ? h : undefined,
65
+ body,
66
+ signal,
67
+ });
68
+ return { status: res.status, text: await res.text() };
69
+ };
70
+ }
71
+ // ── Activities (ActivityFn: (args, signal?) => Promise<unknown>) ──────────────
72
+ /** Write a file into the sprite. `PUT /v1/sprites/{id}/fs/write` with raw body. */
73
+ export async function spriteWriteFile(args, signal, http = defaultSpritesRawHttp(args.token)) {
74
+ const base = resolveSpritesEndpoint(args);
75
+ const url = spriteFsUrl(base, args.id, "write", {
76
+ path: args.path,
77
+ mode: args.mode,
78
+ mkdir: args.mkdir,
79
+ workingDir: args.workingDir,
80
+ });
81
+ const res = await http("PUT", url, args.content, undefined, signal);
82
+ if (res.status >= 300) {
83
+ throw new Error(`sprite ${args.id} write ${args.path} failed (${res.status}): ${res.text}`);
84
+ }
85
+ logProgress(`wrote: sprite/${args.id}:${args.path} (${args.content.length}b)`);
86
+ return {};
87
+ }
88
+ /** Read a file from the sprite. `GET /v1/sprites/{id}/fs/read` → raw body. */
89
+ export async function spriteReadFile(args, signal, http = defaultSpritesRawHttp(args.token)) {
90
+ const base = resolveSpritesEndpoint(args);
91
+ const url = spriteFsUrl(base, args.id, "read", { path: args.path, workingDir: args.workingDir });
92
+ const res = await http("GET", url, undefined, undefined, signal);
93
+ if (res.status === 404)
94
+ throw new Error(`sprite ${args.id} read ${args.path}: not found`);
95
+ if (res.status >= 300)
96
+ throw new Error(`sprite ${args.id} read ${args.path} failed (${res.status}): ${res.text}`);
97
+ return { content: res.text };
98
+ }
99
+ /** List a directory in the sprite. `GET /v1/sprites/{id}/fs/list` → entry array. */
100
+ export async function spriteListDir(args, signal, http = defaultSpritesRawHttp(args.token)) {
101
+ const base = resolveSpritesEndpoint(args);
102
+ const url = spriteFsUrl(base, args.id, "list", { path: args.path, workingDir: args.workingDir });
103
+ const res = await http("GET", url, undefined, undefined, signal);
104
+ if (res.status >= 300)
105
+ throw new Error(`sprite ${args.id} list ${args.path} failed (${res.status}): ${res.text}`);
106
+ const parsed = safeJson(res.text);
107
+ // Accept a bare array or a `{ entries: [...] }` envelope.
108
+ if (Array.isArray(parsed))
109
+ return parsed;
110
+ const entries = parsed?.entries;
111
+ return Array.isArray(entries) ? entries : [];
112
+ }
113
+ /**
114
+ * Remove a path in the sprite. `DELETE /v1/sprites/{id}/fs/delete`. Idempotent:
115
+ * a 404 (already gone) is a no-op, matching `rm -f` and the destroy activity.
116
+ */
117
+ export async function spriteRemove(args, signal, http = defaultSpritesRawHttp(args.token)) {
118
+ const base = resolveSpritesEndpoint(args);
119
+ const url = spriteFsUrl(base, args.id, "delete", {
120
+ path: args.path,
121
+ recursive: args.recursive,
122
+ asRoot: args.asRoot,
123
+ workingDir: args.workingDir,
124
+ });
125
+ const res = await http("DELETE", url, undefined, undefined, signal);
126
+ if (res.status >= 300 && res.status !== 404) {
127
+ throw new Error(`sprite ${args.id} remove ${args.path} failed (${res.status}): ${res.text}`);
128
+ }
129
+ logProgress(`removed: sprite/${args.id}:${args.path}`);
130
+ return {};
131
+ }