@intentius/chant-lexicon-fly 0.89.0 → 0.91.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 (75) hide show
  1. package/dist/components/capability-plugin.d.ts +1 -0
  2. package/dist/components/capability-plugin.d.ts.map +1 -1
  3. package/dist/components/fly-release.d.ts +148 -0
  4. package/dist/components/fly-release.d.ts.map +1 -0
  5. package/dist/components/index.d.ts +2 -0
  6. package/dist/components/index.d.ts.map +1 -1
  7. package/dist/describe-resources.d.ts +11 -0
  8. package/dist/describe-resources.d.ts.map +1 -1
  9. package/dist/index.d.ts +3 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/integrity.json +4 -4
  12. package/dist/manifest.json +1 -1
  13. package/dist/op/activities/emulator-images.d.ts +13 -0
  14. package/dist/op/activities/emulator-images.d.ts.map +1 -1
  15. package/dist/op/activities/fly-apply.d.ts +8 -22
  16. package/dist/op/activities/fly-apply.d.ts.map +1 -1
  17. package/dist/op/activities/index.d.ts +6 -2
  18. package/dist/op/activities/index.d.ts.map +1 -1
  19. package/dist/op/activities/machine-release.d.ts +176 -0
  20. package/dist/op/activities/machine-release.d.ts.map +1 -0
  21. package/dist/op/activities/machines-contract.d.ts +8 -0
  22. package/dist/op/activities/machines-contract.d.ts.map +1 -1
  23. package/dist/op/activities/machines-fake.d.ts +53 -0
  24. package/dist/op/activities/machines-fake.d.ts.map +1 -0
  25. package/dist/op/activities/sprite-services.d.ts +158 -0
  26. package/dist/op/activities/sprite-services.d.ts.map +1 -0
  27. package/dist/op/activities/sprites-contract.d.ts +17 -1
  28. package/dist/op/activities/sprites-contract.d.ts.map +1 -1
  29. package/dist/op/activities/sprites-emulator.d.ts +12 -0
  30. package/dist/op/activities/sprites-emulator.d.ts.map +1 -1
  31. package/dist/op/activities/sprites-fake.d.ts +4 -0
  32. package/dist/op/activities/sprites-fake.d.ts.map +1 -1
  33. package/dist/op/activities/sprites.d.ts +53 -0
  34. package/dist/op/activities/sprites.d.ts.map +1 -1
  35. package/dist/op/builders.d.ts +33 -1
  36. package/dist/op/builders.d.ts.map +1 -1
  37. package/dist/release-metadata.d.ts +48 -0
  38. package/dist/release-metadata.d.ts.map +1 -0
  39. package/dist/release-store.d.ts +36 -0
  40. package/dist/release-store.d.ts.map +1 -0
  41. package/dist/skills/chant-fly-ops.md +33 -0
  42. package/dist/skills/chant-fly-sprites.md +4 -2
  43. package/package.json +2 -2
  44. package/src/components/capability-plugin.ts +11 -3
  45. package/src/components/fly-release.test.ts +244 -0
  46. package/src/components/fly-release.ts +341 -0
  47. package/src/components/index.ts +16 -0
  48. package/src/describe-resources.test.ts +18 -3
  49. package/src/describe-resources.ts +69 -5
  50. package/src/index.ts +21 -0
  51. package/src/op/activities/emulator-images.ts +14 -0
  52. package/src/op/activities/fly-apply.ts +15 -3
  53. package/src/op/activities/index.ts +57 -0
  54. package/src/op/activities/machine-release.integration.test.ts +113 -0
  55. package/src/op/activities/machine-release.test.ts +138 -0
  56. package/src/op/activities/machine-release.ts +407 -0
  57. package/src/op/activities/machines-contract.docker.integration.test.ts +10 -1
  58. package/src/op/activities/machines-contract.test.ts +11 -1
  59. package/src/op/activities/machines-contract.ts +17 -0
  60. package/src/op/activities/machines-fake.ts +165 -0
  61. package/src/op/activities/sprite-config.test.ts +19 -0
  62. package/src/op/activities/sprite-services.docker.integration.test.ts +87 -0
  63. package/src/op/activities/sprite-services.test.ts +263 -0
  64. package/src/op/activities/sprite-services.ts +311 -0
  65. package/src/op/activities/sprites-contract.test.ts +15 -4
  66. package/src/op/activities/sprites-contract.ts +24 -6
  67. package/src/op/activities/sprites-emulator.ts +52 -1
  68. package/src/op/activities/sprites-fake.ts +57 -8
  69. package/src/op/activities/sprites.test.ts +102 -0
  70. package/src/op/activities/sprites.ts +108 -7
  71. package/src/op/builders.ts +53 -1
  72. package/src/release-metadata.ts +72 -0
  73. package/src/release-store.ts +70 -0
  74. package/src/skills/chant-fly-ops.md +33 -0
  75. package/src/skills/chant-fly-sprites.md +4 -2
@@ -6,9 +6,13 @@ import { SPRITES_CONTRACT, normalizeEndpoint, contractKeys } from "./sprites-con
6
6
 
7
7
  describe("SPRITES_CONTRACT", () => {
8
8
  test("covers every sprite activity that calls the Sprites API", () => {
9
- const activities = new Set(SPRITES_CONTRACT.map((e) => e.activity));
10
- // Every activity across the lifecycle, filesystem, config-reconcile, and
11
- // keep-alive modules that makes an HTTP/WS call.
9
+ // A row's `activity` is comma-separated when more than one activity calls
10
+ // that physical endpoint (#2711) — split before comparing.
11
+ const activities = new Set(SPRITES_CONTRACT.flatMap((e) => e.activity.split(",").map((a) => a.trim())));
12
+ // Every activity across the lifecycle, filesystem, config-reconcile,
13
+ // single-service, and keep-alive modules that makes an HTTP/WS call.
14
+ // (spriteUrl, spriteServiceDelete and spriteServiceLogs are deliberately
15
+ // absent — see the module doc.)
12
16
  expect(activities).toEqual(
13
17
  new Set([
14
18
  // lifecycle (./sprites.ts)
@@ -18,6 +22,7 @@ describe("SPRITES_CONTRACT", () => {
18
22
  "listCheckpoints",
19
23
  "spriteRestore",
20
24
  "spriteDestroy",
25
+ "spriteDelete",
21
26
  // filesystem (./sprite-fs.ts)
22
27
  "spriteWriteFile",
23
28
  "spriteReadFile",
@@ -26,6 +31,12 @@ describe("SPRITES_CONTRACT", () => {
26
31
  // config reconcile (./sprite-config.ts)
27
32
  "spriteApplyNetworkPolicy",
28
33
  "spriteApplyServices",
34
+ // single-service (./sprite-services.ts, #2711)
35
+ "spriteServiceCreate",
36
+ "spriteServiceGet",
37
+ "spriteServiceList",
38
+ "spriteServiceStart",
39
+ "spriteServiceStop",
29
40
  // keep-alive tasks (./sprite-tasks.ts)
30
41
  "spriteTaskCreate",
31
42
  "spriteTaskRefresh",
@@ -63,7 +74,7 @@ describe("SPRITES_CONTRACT", () => {
63
74
  // contract must be updated in the same change. Scans every module that owns
64
75
  // contract endpoints, not just the lifecycle one.
65
76
  const dir = dirname(fileURLToPath(import.meta.url));
66
- const src = ["sprites.ts", "sprite-fs.ts", "sprite-config.ts", "sprite-tasks.ts"]
77
+ const src = ["sprites.ts", "sprite-fs.ts", "sprite-config.ts", "sprite-services.ts", "sprite-tasks.ts"]
67
78
  .map((f) => readFileSync(join(dir, f), "utf-8"))
68
79
  .join("\n");
69
80
  const segments = new Set(
@@ -19,6 +19,22 @@
19
19
  * Path params are written with names matching ./sprites.ts (`{id}`, `{cp}`);
20
20
  * the coverage check normalizes param names before comparing, since spritzer
21
21
  * spells the checkpoint id `{cid}`.
22
+ *
23
+ * #2711 adds the imperative Services activities (`./sprite-services.ts`).
24
+ * Several of them share a physical endpoint with `spriteApplyServices`'s own
25
+ * calls (one `PUT`/one `GET .../services`/one `.../start` — the reconcile
26
+ * activity and the single-service primitives are two callers of the same
27
+ * REST surface), so one row lists every activity that calls it, comma
28
+ * separated, rather than one row per activity — `contractKeys()` stays one
29
+ * key per physical endpoint. `spriteServiceDelete` and `spriteServiceLogs`
30
+ * are deliberately absent: spritzer's interpreter mode (the coverage test's
31
+ * oracle) doesn't implement them, only its container-mode agent does, which
32
+ * has no per-verb entry in `/_spritzer/health` (real.go reports the whole
33
+ * `/v1/sprites/{id}/services/...` proxy as one wildcard line — nothing to
34
+ * normalize-compare against a specific verb). They're exercised instead by
35
+ * `sprite-services.docker.integration.test.ts` against real container-mode
36
+ * spritzer. `spriteUrl` is absent for the same reason, plus its target
37
+ * (`/s/{name}/...`) isn't under `/v1/sprites` at all.
22
38
  */
23
39
 
24
40
  /** One endpoint the fly sprite activities call. */
@@ -27,7 +43,7 @@ export interface SpritesEndpoint {
27
43
  method: "GET" | "POST" | "PUT" | "DELETE" | "WS";
28
44
  /** Path template under the Sprites base, e.g. `/v1/sprites/{id}/checkpoint`. */
29
45
  path: string;
30
- /** The fly activity that calls it (an activity module export). */
46
+ /** The fly activity/activities that call it (comma-separated when more than one shares the endpoint). */
31
47
  activity: string;
32
48
  }
33
49
 
@@ -46,7 +62,7 @@ export const SPRITES_CONTRACT: readonly SpritesEndpoint[] = [
46
62
  { method: "POST", path: "/v1/sprites/{id}/checkpoint", activity: "spriteCheckpoint" },
47
63
  { method: "GET", path: "/v1/sprites/{id}/checkpoints", activity: "listCheckpoints" },
48
64
  { method: "POST", path: "/v1/sprites/{id}/checkpoints/{cp}/restore", activity: "spriteRestore" },
49
- { method: "DELETE", path: "/v1/sprites/{id}", activity: "spriteDestroy" },
65
+ { method: "DELETE", path: "/v1/sprites/{id}", activity: "spriteDestroy, spriteDelete" },
50
66
  // Filesystem (./sprite-fs.ts)
51
67
  { method: "PUT", path: "/v1/sprites/{id}/fs/write", activity: "spriteWriteFile" },
52
68
  { method: "GET", path: "/v1/sprites/{id}/fs/read", activity: "spriteReadFile" },
@@ -55,10 +71,12 @@ export const SPRITES_CONTRACT: readonly SpritesEndpoint[] = [
55
71
  // Network policy (./sprite-config.ts)
56
72
  { method: "GET", path: "/v1/sprites/{id}/policy/network", activity: "spriteApplyNetworkPolicy" },
57
73
  { method: "POST", path: "/v1/sprites/{id}/policy/network", activity: "spriteApplyNetworkPolicy" },
58
- // Services (./sprite-config.ts)
59
- { method: "GET", path: "/v1/sprites/{id}/services", activity: "spriteApplyServices" },
60
- { method: "PUT", path: "/v1/sprites/{id}/services/{svc}", activity: "spriteApplyServices" },
61
- { method: "POST", path: "/v1/sprites/{id}/services/{svc}/start", activity: "spriteApplyServices" },
74
+ // Services (./sprite-config.ts, ./sprite-services.ts)
75
+ { method: "GET", path: "/v1/sprites/{id}/services", activity: "spriteApplyServices, spriteServiceList" },
76
+ { method: "GET", path: "/v1/sprites/{id}/services/{svc}", activity: "spriteServiceGet" },
77
+ { method: "PUT", path: "/v1/sprites/{id}/services/{svc}", activity: "spriteApplyServices, spriteServiceCreate" },
78
+ { method: "POST", path: "/v1/sprites/{id}/services/{svc}/start", activity: "spriteApplyServices, spriteServiceStart" },
79
+ { method: "POST", path: "/v1/sprites/{id}/services/{svc}/stop", activity: "spriteServiceStop" },
62
80
  // Keep-alive tasks (./sprite-tasks.ts)
63
81
  { method: "POST", path: "/v1/sprites/{id}/tasks", activity: "spriteTaskCreate" },
64
82
  { method: "PUT", path: "/v1/sprites/{id}/tasks/{name}", activity: "spriteTaskRefresh" },
@@ -1,5 +1,5 @@
1
1
  import { emulatorLifecycle, type EmulatorCapability, type EmulatorSpec } from "@intentius/chant/op";
2
- import { SPRITZER_IMAGE } from "./emulator-images";
2
+ import { SPRITZER_IMAGE, SPRITZER_CONTAINER_IMAGE } from "./emulator-images";
3
3
 
4
4
  export interface SpritesUpArgs {
5
5
  /** Container name. Default: `chant-spritzer`. */
@@ -53,3 +53,54 @@ export const spritesUp = (args: SpritesUpArgs = {}, signal?: AbortSignal): Promi
53
53
  /** Stop and remove the local spritzer container (no-op if already gone). */
54
54
  export const spritesDown = (args: SpritesDownArgs = {}, signal?: AbortSignal): Promise<void> =>
55
55
  spritzer.down(args, signal);
56
+
57
+ // ── Container exec mode (#2711, INTENTIUS/spritzer#22) ─────────────────────────
58
+ //
59
+ // A second, distinct spritzer lifecycle: `SPRITZER_EXEC=container` with the
60
+ // Docker runtime, so each sprite it makes is a real container — the mode the
61
+ // Services CRUD activities (`./sprite-services.ts`) and `spriteUrl` need (real
62
+ // processes, a real sprite URL), and that `SPRITZER_SPEC` above (interpreter
63
+ // mode, the default everything else tests against) does not run. The Docker
64
+ // socket is mounted read-write so spritzer can create/exec/delete sibling
65
+ // sprite containers through it (docker-outside-of-docker); no port publishing
66
+ // is needed for the sprites themselves — spritzer reaches them by exec, and
67
+ // proxies their URL (`/s/{name}/...`) through the one published port.
68
+
69
+ /** Container exec mode spritzer, pinned to {@link SPRITZER_CONTAINER_IMAGE} (0.6.0, the first release with this mode). */
70
+ export const SPRITZER_CONTAINER_SPEC: EmulatorSpec = {
71
+ name: "chant-spritzer-container",
72
+ image: SPRITZER_CONTAINER_IMAGE,
73
+ containerPort: 4290,
74
+ healthPath: "/_spritzer/health",
75
+ runArgs: [
76
+ // The image runs as `nonroot` by default, which cannot open a
77
+ // group/other-unwritable host socket — root inside the container is
78
+ // still an unprivileged Linux user account relative to the host/Docker
79
+ // Desktop VM, no different from any other `docker run` that mounts the
80
+ // socket.
81
+ "--user",
82
+ "root",
83
+ "-e",
84
+ "SPRITZER_EXEC=container",
85
+ "-e",
86
+ "SPRITZER_RUNTIME=docker",
87
+ "-v",
88
+ "/var/run/docker.sock:/var/run/docker.sock",
89
+ ],
90
+ upstream: { repo: "intentius/spritzer" },
91
+ };
92
+
93
+ const spritzerContainer = emulatorLifecycle(SPRITZER_CONTAINER_SPEC);
94
+
95
+ export const spritesContainerRunCommand = (args: SpritesUpArgs = {}): string => spritzerContainer.runCommand(args);
96
+ export const spritesContainerExistsCommand = spritzerContainer.existsCommand;
97
+ export const spritesContainerRmCommand = spritzerContainer.rmCommand;
98
+ export const spritesContainerHealthUrl = spritzerContainer.healthUrl;
99
+
100
+ /** Boot a local spritzer in container exec mode (Docker runtime) and return its endpoint. */
101
+ export const spritesContainerUp = (args: SpritesUpArgs = {}, signal?: AbortSignal): Promise<{ endpoint: string }> =>
102
+ spritzerContainer.up(args, signal);
103
+
104
+ /** Stop and remove the local container-exec-mode spritzer (no-op if already gone). Sprite containers it made are the caller's to clean up (see the integration test). */
105
+ export const spritesContainerDown = (args: SpritesDownArgs = {}, signal?: AbortSignal): Promise<void> =>
106
+ spritzerContainer.down(args, signal);
@@ -41,7 +41,10 @@ interface StoredService {
41
41
  dir?: string;
42
42
  needs?: string[];
43
43
  http_port?: number;
44
- state: { name: string; pid: number; status: string; started_at?: string };
44
+ state: { name: string; pid: number; status: string; started_at?: string; error?: string };
45
+ /** Synthetic log lines (#2711) — the fake runs no real process, so `logs` is a
46
+ * scripted trail of lifecycle events, enough to exercise `spriteServiceLogs`. */
47
+ logs: string[];
45
48
  }
46
49
 
47
50
  interface SpriteState {
@@ -244,6 +247,11 @@ export function createSpritesFake(): Promise<{ url: string; close(): Promise<voi
244
247
  res.writeHead(status, { "content-type": "application/json" });
245
248
  res.end(JSON.stringify(body ?? {}));
246
249
  };
250
+ // No-body reply (real Sprites' 204s carry no content and no body): #2719.
251
+ const sendEmpty = (status: number): void => {
252
+ res.writeHead(status);
253
+ res.end();
254
+ };
247
255
  // NDJSON progress stream: an `info` line then a terminal `complete` line.
248
256
  const sendNdjson = (status: number, events: Array<Record<string, unknown>>): void => {
249
257
  res.writeHead(status, { "content-type": "application/x-ndjson" });
@@ -311,13 +319,14 @@ export function createSpritesFake(): Promise<{ url: string; close(): Promise<voi
311
319
  if (method === "POST") {
312
320
  const body = ((await readBody(req)) ?? {}) as { rules?: Array<{ domain: string; action: string }> };
313
321
  sprite.netPolicy = body.rules ?? [];
314
- return send(200, { rules: sprite.netPolicy });
322
+ // 204 with no body — matches every official SDK and wisp (#2719).
323
+ return sendEmpty(204);
315
324
  }
316
325
  return send(404, { error: `not found: ${method} ${path}` });
317
326
  }
318
327
 
319
- // Services: /v1/sprites/{id}/services[/{svc}[/start|stop|restart]].
320
- const svcm = path.match(/^\/v1\/sprites\/([^/]+)\/services(?:\/([^/]+)(\/start|\/stop|\/restart)?)?\/?$/);
328
+ // Services: /v1/sprites/{id}/services[/{svc}[/start|stop|restart|logs]].
329
+ const svcm = path.match(/^\/v1\/sprites\/([^/]+)\/services(?:\/([^/]+)(\/start|\/stop|\/restart|\/logs)?)?\/?$/);
321
330
  if (svcm) {
322
331
  const id = decodeURIComponent(svcm[1]);
323
332
  const svc = svcm[2] ? decodeURIComponent(svcm[2]) : undefined;
@@ -328,15 +337,30 @@ export function createSpritesFake(): Promise<{ url: string; close(): Promise<voi
328
337
  // GET /services — list.
329
338
  if (method === "GET" && !svc) return send(200, Object.values(sprite.services));
330
339
 
340
+ // GET /services/{svc}/logs — the tail as NDJSON (#2711).
341
+ if (method === "GET" && svc && action === "/logs") {
342
+ const s = sprite.services[svc];
343
+ if (!s) return send(404, { error: `no service ${svc}` });
344
+ const n = Number(url.searchParams.get("lines"));
345
+ const tail = Number.isFinite(n) && n > 0 ? s.logs.slice(-n) : s.logs;
346
+ return sendNdjson(
347
+ 200,
348
+ [...tail.map((data) => ({ type: "stdout", data })), { type: "complete", data: `${svc} log tail` }],
349
+ );
350
+ }
351
+
331
352
  if (svc && !action) {
332
353
  // GET /services/{svc}
333
354
  if (method === "GET") {
334
355
  const s = sprite.services[svc];
335
356
  return s ? send(200, s) : send(404, { error: `no service ${svc}` });
336
357
  }
337
- // PUT /services/{svc} — create or update.
358
+ // PUT /services/{svc} — create-or-update, then start (#2711: real
359
+ // Sprites/spritzer's PUT both defines and starts the service).
338
360
  if (method === "PUT") {
339
- const b = ((await readBody(req)) ?? {}) as Omit<StoredService, "name" | "state">;
361
+ const b = ((await readBody(req)) ?? {}) as Omit<StoredService, "name" | "state" | "logs">;
362
+ const prev = sprite.services[svc];
363
+ const started = new Date().toISOString();
340
364
  sprite.services[svc] = {
341
365
  name: svc,
342
366
  cmd: b.cmd,
@@ -345,18 +369,26 @@ export function createSpritesFake(): Promise<{ url: string; close(): Promise<voi
345
369
  dir: b.dir,
346
370
  needs: b.needs,
347
371
  http_port: b.http_port,
348
- state: sprite.services[svc]?.state ?? { name: svc, pid: 0, status: "stopped" },
372
+ state: { name: svc, pid: 4321, status: "running", started_at: started },
373
+ logs: [...(prev?.logs ?? []), `${svc} started (pid 4321)`],
349
374
  };
350
375
  return send(200, sprite.services[svc]);
351
376
  }
377
+ // DELETE /services/{svc} — idempotent; a 404 means already gone (#2711).
378
+ if (method === "DELETE") {
379
+ if (!(svc in sprite.services)) return send(404, { error: `no service ${svc}` });
380
+ delete sprite.services[svc];
381
+ return send(200, {});
382
+ }
352
383
  }
353
384
 
354
385
  // POST /services/{svc}/start|stop|restart — NDJSON, flips status.
355
- if (method === "POST" && svc && action) {
386
+ if (method === "POST" && svc && (action === "/start" || action === "/stop" || action === "/restart")) {
356
387
  const s = sprite.services[svc];
357
388
  if (!s) return send(404, { error: `no service ${svc}` });
358
389
  const stopped = action === "/stop";
359
390
  s.state = { name: svc, pid: stopped ? 0 : 4321, status: stopped ? "stopped" : "running", started_at: new Date().toISOString() };
391
+ s.logs.push(`${svc} ${stopped ? "stopping" : "started"}`);
360
392
  return sendNdjson(200, [
361
393
  { type: stopped ? "stopping" : "started", data: `${svc} ${stopped ? "stopping" : "started"}` },
362
394
  { type: "complete", data: `${svc} ${action.slice(1)} complete` },
@@ -365,6 +397,23 @@ export function createSpritesFake(): Promise<{ url: string; close(): Promise<voi
365
397
  return send(404, { error: `not found: ${method} ${path}` });
366
398
  }
367
399
 
400
+ // The sprite URL proxy: /s/{id}[/...] (#2711). Real spritzer routes this to
401
+ // whatever listens on the `http_port` service's port; the fake has no real
402
+ // process, so it answers 200 when such a service is `running` and 503
403
+ // ("nothing is answering", matching spritzer's real error) otherwise —
404
+ // enough for `spriteUrl`'s wait-for-200 and the stop/start round trip.
405
+ const sm = path.match(/^\/s\/([^/]+)(?:\/.*)?$/);
406
+ if (sm) {
407
+ const id = decodeURIComponent(sm[1]);
408
+ const sprite = sprites.get(id);
409
+ if (!sprite || sprite.status === "destroyed") return send(404, { error: `no sprite ${id}` });
410
+ const serving = Object.values(sprite.services).find((s) => s.http_port !== undefined && s.state.status === "running");
411
+ if (serving) return send(200, { ok: true, service: serving.name });
412
+ res.writeHead(503, { "content-type": "application/json", "retry-after": "2" });
413
+ res.end(JSON.stringify({ error: `sprite ${id}: nothing is answering on its url port` }));
414
+ return;
415
+ }
416
+
368
417
  // Filesystem API: /v1/sprites/{id}/fs/{read|write|list|delete}. read/write
369
418
  // move raw bytes; list/delete use query params + JSON/empty responses.
370
419
  const fsm = path.match(/^\/v1\/sprites\/([^/]+)\/fs\/(read|write|list|delete)\/?$/);
@@ -1,6 +1,7 @@
1
1
  import { describe, test, expect } from "vitest";
2
2
  import {
3
3
  resolveSpritesEndpoint,
4
+ resolveSpritesToken,
4
5
  DEFAULT_SPRITES_BASE_URL,
5
6
  accumulateExecFrames,
6
7
  parseCheckpointNdjson,
@@ -13,6 +14,8 @@ import {
13
14
  spriteRestore,
14
15
  listCheckpoints,
15
16
  spriteDestroy,
17
+ spriteDelete,
18
+ spriteUrl,
16
19
  type SpritesHttp,
17
20
  type Checkpoint,
18
21
  } from "./sprites";
@@ -141,6 +144,50 @@ describe("resolveSpritesEndpoint (S3)", () => {
141
144
  test("default is real Sprites", () => {
142
145
  expect(resolveSpritesEndpoint({}, {} as NodeJS.ProcessEnv)).toBe(DEFAULT_SPRITES_BASE_URL);
143
146
  });
147
+
148
+ // #2711 — the studio and wisp call it SPRITES_API_URL.
149
+ test("SPRITES_API_URL alias when SPRITES_BASE_URL is unset", () => {
150
+ expect(resolveSpritesEndpoint({}, { SPRITES_API_URL: "http://alias:9000" } as NodeJS.ProcessEnv)).toBe(
151
+ "http://alias:9000",
152
+ );
153
+ });
154
+
155
+ test("SPRITES_BASE_URL wins over SPRITES_API_URL", () => {
156
+ expect(
157
+ resolveSpritesEndpoint(
158
+ {},
159
+ { SPRITES_BASE_URL: "http://base:1", SPRITES_API_URL: "http://alias:2" } as NodeJS.ProcessEnv,
160
+ ),
161
+ ).toBe("http://base:1");
162
+ });
163
+ });
164
+
165
+ describe("resolveSpritesToken (#2711)", () => {
166
+ test("explicit arg wins", () => {
167
+ expect(resolveSpritesToken("arg-token", { SPRITES_API_TOKEN: "env" } as NodeJS.ProcessEnv)).toBe("arg-token");
168
+ });
169
+
170
+ test("SPRITES_API_TOKEN env when no arg", () => {
171
+ expect(resolveSpritesToken(undefined, { SPRITES_API_TOKEN: "env-token" } as NodeJS.ProcessEnv)).toBe(
172
+ "env-token",
173
+ );
174
+ });
175
+
176
+ test("SPRITE_TOKEN alias when SPRITES_API_TOKEN is unset", () => {
177
+ expect(resolveSpritesToken(undefined, { SPRITE_TOKEN: "alias-token" } as NodeJS.ProcessEnv)).toBe(
178
+ "alias-token",
179
+ );
180
+ });
181
+
182
+ test("SPRITES_API_TOKEN wins over SPRITE_TOKEN", () => {
183
+ expect(
184
+ resolveSpritesToken(undefined, { SPRITES_API_TOKEN: "orig", SPRITE_TOKEN: "alias" } as NodeJS.ProcessEnv),
185
+ ).toBe("orig");
186
+ });
187
+
188
+ test("undefined when nothing is set", () => {
189
+ expect(resolveSpritesToken(undefined, {} as NodeJS.ProcessEnv)).toBeUndefined();
190
+ });
144
191
  });
145
192
 
146
193
  // ── Command tokenizing + exec WS url ──────────────────────────────────────────
@@ -263,6 +310,61 @@ describe("spriteDestroy", () => {
263
310
  });
264
311
  });
265
312
 
313
+ describe("spriteDelete (#2711)", () => {
314
+ test("is spriteDestroy under another name — same DELETE, same idempotence", async () => {
315
+ expect(spriteDelete).toBe(spriteDestroy);
316
+ const { http, calls } = recorder(() => ({ status: 200, text: "{}" }));
317
+ await spriteDelete({ id: "task-1", endpoint: "http://x" }, undefined, http);
318
+ expect(calls[0].method).toBe("DELETE");
319
+ expect(calls[0].url).toBe("http://x/v1/sprites/task-1");
320
+ });
321
+ });
322
+
323
+ describe("spriteUrl (#2711)", () => {
324
+ test("GETs /v1/sprites/{id} and returns its url", async () => {
325
+ const { http, calls } = recorder(() => ({ status: 200, text: JSON.stringify({ url: "http://h/s/task-1" }) }));
326
+ const res = await spriteUrl({ id: "task-1", endpoint: "http://x" }, undefined, http);
327
+ expect(res).toEqual({ url: "http://h/s/task-1" });
328
+ expect(calls[0].method).toBe("GET");
329
+ expect(calls[0].url).toBe("http://x/v1/sprites/task-1");
330
+ });
331
+
332
+ test("throws when the sprite has no url", async () => {
333
+ const { http } = recorder(() => ({ status: 200, text: "{}" }));
334
+ await expect(spriteUrl({ id: "task-1", endpoint: "http://x" }, undefined, http)).rejects.toThrow(/no url/);
335
+ });
336
+
337
+ test("with a path: returns immediately once the check answers 2xx", async () => {
338
+ let checks = 0;
339
+ const { http, calls } = recorder((method, url) => {
340
+ if (method === "GET" && url === "http://x/v1/sprites/task-1") {
341
+ return { status: 200, text: JSON.stringify({ url: "http://h/s/task-1" }) };
342
+ }
343
+ checks += 1;
344
+ return { status: checks < 2 ? 503 : 200, text: "" };
345
+ });
346
+ const res = await spriteUrl(
347
+ { id: "task-1", endpoint: "http://x", path: "/", intervalMs: 1 },
348
+ undefined,
349
+ http,
350
+ );
351
+ expect(res).toEqual({ url: "http://h/s/task-1" });
352
+ expect(checks).toBe(2); // one 503, then 200
353
+ expect(calls.at(-1)?.url).toBe("http://h/s/task-1/");
354
+ });
355
+
356
+ test("with a path: throws once timeoutMs elapses without a match", async () => {
357
+ const { http } = recorder((method, url) =>
358
+ url === "http://x/v1/sprites/task-1"
359
+ ? { status: 200, text: JSON.stringify({ url: "http://h/s/task-1" }) }
360
+ : { status: 503, text: "nothing is answering" },
361
+ );
362
+ await expect(
363
+ spriteUrl({ id: "task-1", endpoint: "http://x", path: "/", timeoutMs: 5, intervalMs: 1 }, undefined, http),
364
+ ).rejects.toThrow(/did not answer/);
365
+ });
366
+ });
367
+
266
368
  // ── Bearer header on the default client ───────────────────────────────────────
267
369
 
268
370
  describe("defaultSpritesHttp bearer header", () => {
@@ -20,6 +20,13 @@
20
20
  * S3: endpoint override via `SPRITES_BASE_URL` (an explicit `endpoint` arg wins,
21
21
  * then the env, then the real Sprites base), so the same Op targets real Sprites
22
22
  * or the in-process fake with no code change.
23
+ *
24
+ * #2711: the studio and wisp's own docs call these `SPRITES_API_URL` and
25
+ * `SPRITE_TOKEN` rather than `SPRITES_BASE_URL`/`SPRITES_API_TOKEN`. Both
26
+ * pairs are accepted — `resolveSpritesEndpoint`/`resolveSpritesToken` read the
27
+ * alias when the original name is unset, with the original always winning
28
+ * when both are present, so an existing deployment is never surprised by the
29
+ * alias taking over.
23
30
  */
24
31
 
25
32
  // `ws` is loaded inside spriteExec, not at the top. It is a CommonJS package
@@ -27,6 +34,8 @@
27
34
  // ESM, where that require throws. The top-level import put it on the path of
28
35
  // every project that imports the lexicon and needs the run fallback (#2613).
29
36
 
37
+ import { sleep } from "@intentius/chant/op";
38
+
30
39
  export const DEFAULT_SPRITES_BASE_URL = "https://api.sprites.dev";
31
40
 
32
41
  // ── Pure helpers (unit-testable without http/ws) ──────────────────────────────
@@ -41,15 +50,27 @@ export function resolveSpritesEndpoint(
41
50
  args: { endpoint?: string } = {},
42
51
  env: NodeJS.ProcessEnv = process.env,
43
52
  ): string {
44
- const base = args.endpoint || env.SPRITES_BASE_URL || DEFAULT_SPRITES_BASE_URL;
53
+ const base = args.endpoint || env.SPRITES_BASE_URL || env.SPRITES_API_URL || DEFAULT_SPRITES_BASE_URL;
45
54
  return base.replace(/\/$/, "");
46
55
  }
47
56
 
57
+ /**
58
+ * Resolve the bearer token (#2711): an explicit `token` arg wins, then
59
+ * `SPRITES_API_TOKEN`, then its alias `SPRITE_TOKEN`. Pure — mirrors
60
+ * `resolveSpritesEndpoint`.
61
+ */
62
+ export function resolveSpritesToken(
63
+ token: string | undefined = undefined,
64
+ env: NodeJS.ProcessEnv = process.env,
65
+ ): string | undefined {
66
+ return token ?? env.SPRITES_API_TOKEN ?? env.SPRITE_TOKEN;
67
+ }
68
+
48
69
  const spritesUrl = (base: string): string => `${base}/v1/sprites`;
49
- const spriteUrl = (base: string, id: string): string => `${spritesUrl(base)}/${encodeURIComponent(id)}`;
70
+ const spriteResourceUrl = (base: string, id: string): string => `${spritesUrl(base)}/${encodeURIComponent(id)}`;
50
71
  // Create uses REST JSON; exec is the control WebSocket below; checkpoints are NDJSON.
51
- const spriteCheckpointUrl = (base: string, id: string): string => `${spriteUrl(base, id)}/checkpoint`;
52
- const spriteCheckpointsUrl = (base: string, id: string): string => `${spriteUrl(base, id)}/checkpoints`;
72
+ const spriteCheckpointUrl = (base: string, id: string): string => `${spriteResourceUrl(base, id)}/checkpoint`;
73
+ const spriteCheckpointsUrl = (base: string, id: string): string => `${spriteResourceUrl(base, id)}/checkpoints`;
53
74
  const spriteCheckpointRestoreUrl = (base: string, id: string, cp: string): string =>
54
75
  `${spriteCheckpointsUrl(base, id)}/${encodeURIComponent(cp)}/restore`;
55
76
 
@@ -354,7 +375,7 @@ export function defaultSpritesHttp(token?: string, fetchImpl: typeof fetch = fet
354
375
  return async (method, url, body, headers, signal) => {
355
376
  const h: Record<string, string> = { ...headers };
356
377
  if (body !== undefined) h["content-type"] = "application/json";
357
- const tok = token ?? process.env.SPRITES_API_TOKEN;
378
+ const tok = resolveSpritesToken(token);
358
379
  if (tok) h["authorization"] = `Bearer ${tok}`;
359
380
  const res = await fetchImpl(url, {
360
381
  method,
@@ -393,7 +414,7 @@ export async function spriteCreate(
393
414
  export async function spriteExec(args: SpriteExecArgs, signal?: AbortSignal): Promise<SpriteExecResult> {
394
415
  const base = resolveSpritesEndpoint(args);
395
416
  const url = spriteExecWsUrl(base, args.id, args.cmd);
396
- const token = args.token ?? process.env.SPRITES_API_TOKEN;
417
+ const token = resolveSpritesToken(args.token);
397
418
  const headers: Record<string, string> = {};
398
419
  if (token) headers.Authorization = `Bearer ${token}`;
399
420
  const { default: WebSocket } = await import("ws");
@@ -522,10 +543,90 @@ export async function spriteDestroy(
522
543
  http: SpritesHttp = defaultSpritesHttp(args.token),
523
544
  ): Promise<Record<string, never>> {
524
545
  const base = resolveSpritesEndpoint(args);
525
- const res = await http("DELETE", spriteUrl(base, args.id), undefined, undefined, signal);
546
+ const res = await http("DELETE", spriteResourceUrl(base, args.id), undefined, undefined, signal);
526
547
  if (res.status >= 300 && res.status !== 404) {
527
548
  throw new Error(`sprite ${args.id} destroy failed (${res.status}): ${res.text}`);
528
549
  }
529
550
  console.log(`destroyed: sprite/${args.id} (${base})`);
530
551
  return {};
531
552
  }
553
+
554
+ /**
555
+ * Delete the sprite (#2711) — the same idempotent `DELETE /v1/sprites/{id}` as
556
+ * `spriteDestroy`, under the name spritzer, wisp and the other lexicons'
557
+ * delete activities (`awsDelete`, `gcpDelete`, `azDelete`) use. A literal
558
+ * alias, not a reimplementation, so the two names can never drift: an Op
559
+ * authored against either resolves to the same call. `spriteDestroy` stays —
560
+ * it is the name every existing Op, example and the `op-verb-class` registry
561
+ * already depend on.
562
+ */
563
+ export const spriteDelete = spriteDestroy;
564
+ export type SpriteDeleteArgs = SpriteDestroyArgs;
565
+
566
+ export interface SpriteUrlArgs {
567
+ /** Target sprite id. */
568
+ id: string;
569
+ /**
570
+ * Wait until this path on the sprite's URL answers, instead of returning
571
+ * the URL immediately. Unset (default): no wait, just resolve the URL.
572
+ */
573
+ path?: string;
574
+ /** Expected status while waiting. Default: any 2xx. */
575
+ status?: number;
576
+ /** Max time to wait for `path` to answer, ms. Default: `10000`. */
577
+ timeoutMs?: number;
578
+ /** Delay between polls, ms. Default: `500`. */
579
+ intervalMs?: number;
580
+ endpoint?: string;
581
+ token?: string;
582
+ }
583
+
584
+ export interface SpriteUrlResult {
585
+ url: string;
586
+ }
587
+
588
+ /**
589
+ * Resolve the sprite's URL (#2711) — `spriteCreate` returns it too, but
590
+ * nothing until now exposed it on its own (for a later phase that only has
591
+ * the sprite `id`) or waited on it. `GET /v1/sprites/{id}` for the URL; with
592
+ * `path` set, polls `GET {url}{path}` until `status` (default any 2xx) or
593
+ * `timeoutMs` runs out — the box's door/hud/chud services take a moment to
594
+ * bind their `http_port` after `spriteServiceStart`.
595
+ */
596
+ export async function spriteUrl(
597
+ args: SpriteUrlArgs,
598
+ signal?: AbortSignal,
599
+ http: SpritesHttp = defaultSpritesHttp(args.token),
600
+ ): Promise<SpriteUrlResult> {
601
+ const base = resolveSpritesEndpoint(args);
602
+ const res = await http("GET", spriteResourceUrl(base, args.id), undefined, undefined, signal);
603
+ if (res.status >= 300) throw new Error(`sprite ${args.id} url lookup failed (${res.status}): ${res.text}`);
604
+ const url = (safeJson(res.text) as { url?: string } | undefined)?.url;
605
+ if (!url) throw new Error(`sprite ${args.id} has no url`);
606
+
607
+ if (args.path !== undefined) {
608
+ const timeoutMs = args.timeoutMs ?? 10_000;
609
+ const intervalMs = args.intervalMs ?? 500;
610
+ const target = `${url}${args.path}`;
611
+ const deadline = Date.now() + timeoutMs;
612
+ let lastErr = "";
613
+ for (;;) {
614
+ if (signal?.aborted) throw new Error(`sprite ${args.id} url wait aborted`);
615
+ try {
616
+ const check = await http("GET", target, undefined, undefined, signal);
617
+ const ok = args.status !== undefined ? check.status === args.status : check.status >= 200 && check.status < 300;
618
+ if (ok) {
619
+ console.log(`url: sprite/${args.id} answers on ${target} (${check.status})`);
620
+ return { url };
621
+ }
622
+ lastErr = `status ${check.status} (want ${args.status ?? "2xx"})`;
623
+ } catch (e) {
624
+ lastErr = e instanceof Error ? e.message : String(e);
625
+ }
626
+ if (Date.now() >= deadline) throw new Error(`sprite ${args.id} url ${target} did not answer: ${lastErr}`);
627
+ await sleep(intervalMs, signal);
628
+ }
629
+ }
630
+
631
+ return { url };
632
+ }