@intentius/chant-lexicon-fly 0.18.5 → 0.18.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,246 @@
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
+
20
+ import { resolveSpritesEndpoint, defaultSpritesHttp, type SpritesHttp } from "./sprites";
21
+
22
+ function safeJson(text: string): unknown {
23
+ try {
24
+ return JSON.parse(text);
25
+ } catch {
26
+ return undefined;
27
+ }
28
+ }
29
+
30
+ // ── Network policy ──────────────────────────────────────────────────────────────
31
+
32
+ /** One outbound rule. Ordered — specificity is positional, so order is significant. */
33
+ export interface NetworkRule {
34
+ domain: string;
35
+ action: "allow" | "deny";
36
+ }
37
+
38
+ export interface SpriteApplyNetworkPolicyArgs {
39
+ id: string;
40
+ /** The complete desired ruleset (whole-object replace). */
41
+ rules: NetworkRule[];
42
+ endpoint?: string;
43
+ token?: string;
44
+ }
45
+
46
+ export interface SpriteApplyNetworkPolicyResult {
47
+ /** True when the live policy differed and was replaced; false when already converged. */
48
+ changed: boolean;
49
+ }
50
+
51
+ /**
52
+ * Validate a ruleset: every rule needs a non-empty `domain` and an `allow`/`deny`
53
+ * `action`. Pure; throws on the first offender.
54
+ */
55
+ export function validateNetworkRules(rules: NetworkRule[]): void {
56
+ rules.forEach((r, i) => {
57
+ if (!r || typeof r.domain !== "string" || r.domain.trim() === "") {
58
+ throw new Error(`network rule ${i}: missing domain`);
59
+ }
60
+ if (r.action !== "allow" && r.action !== "deny") {
61
+ throw new Error(`network rule ${i} (${r.domain}): action must be "allow" or "deny", got ${JSON.stringify(r.action)}`);
62
+ }
63
+ });
64
+ }
65
+
66
+ /** Order-sensitive equality of two rulesets (position is significant). Pure. */
67
+ export function networkRulesEqual(a: NetworkRule[], b: NetworkRule[]): boolean {
68
+ if (a.length !== b.length) return false;
69
+ return a.every((r, i) => r.domain === b[i].domain && r.action === b[i].action);
70
+ }
71
+
72
+ const policyUrl = (base: string, id: string): string =>
73
+ `${base}/v1/sprites/${encodeURIComponent(id)}/policy/network`;
74
+
75
+ /**
76
+ * Reconcile a Sprite's outbound network policy. GET the live ruleset, and POST
77
+ * the desired set only when it differs (`GET`/`POST /policy/network`). Returns
78
+ * whether a change was applied.
79
+ */
80
+ export async function spriteApplyNetworkPolicy(
81
+ args: SpriteApplyNetworkPolicyArgs,
82
+ signal?: AbortSignal,
83
+ http: SpritesHttp = defaultSpritesHttp(args.token),
84
+ ): Promise<SpriteApplyNetworkPolicyResult> {
85
+ validateNetworkRules(args.rules);
86
+ const base = resolveSpritesEndpoint(args);
87
+ const url = policyUrl(base, args.id);
88
+
89
+ const cur = await http("GET", url, undefined, undefined, signal);
90
+ if (cur.status >= 300) throw new Error(`sprite ${args.id} get policy failed (${cur.status}): ${cur.text}`);
91
+ const live = (safeJson(cur.text) as { rules?: NetworkRule[] } | undefined)?.rules ?? [];
92
+ if (networkRulesEqual(live, args.rules)) {
93
+ console.log(`policy: sprite/${args.id} already converged (${args.rules.length} rules)`);
94
+ return { changed: false };
95
+ }
96
+
97
+ const res = await http("POST", url, { rules: args.rules }, undefined, signal);
98
+ if (res.status >= 300) throw new Error(`sprite ${args.id} set policy failed (${res.status}): ${res.text}`);
99
+ console.log(`policy: sprite/${args.id} applied ${args.rules.length} rules (${base})`);
100
+ return { changed: true };
101
+ }
102
+
103
+ // ── Services ─────────────────────────────────────────────────────────────────────
104
+
105
+ /** A desired background service. Keyed by `name`; PUT is create-or-update. */
106
+ export interface ServiceSpec {
107
+ name: string;
108
+ cmd: string;
109
+ args?: string[];
110
+ env?: Record<string, string>;
111
+ dir?: string;
112
+ /** Names of services that must start first. */
113
+ needs?: string[];
114
+ /** Route the Sprite's public URL to this port. */
115
+ http_port?: number;
116
+ }
117
+
118
+ export interface SpriteApplyServicesArgs {
119
+ id: string;
120
+ services: ServiceSpec[];
121
+ /** Start each service after applying (in dependency order). Default: false. */
122
+ start?: boolean;
123
+ endpoint?: string;
124
+ token?: string;
125
+ }
126
+
127
+ export interface SpriteApplyServicesResult {
128
+ /** Names that were created or updated (converged services are skipped). */
129
+ applied: string[];
130
+ /** Names that were started (empty unless `start`). */
131
+ started: string[];
132
+ }
133
+
134
+ /**
135
+ * Validate a service set: names are unique, every `needs` target exists, and the
136
+ * dependency graph is acyclic. Pure; throws on the first violation. Returns the
137
+ * names in a valid start order (dependencies first).
138
+ */
139
+ export function validateServices(services: ServiceSpec[]): string[] {
140
+ const byName = new Map<string, ServiceSpec>();
141
+ for (const s of services) {
142
+ if (!s.name) throw new Error("service: missing name");
143
+ if (byName.has(s.name)) throw new Error(`service ${s.name}: duplicate name`);
144
+ byName.set(s.name, s);
145
+ }
146
+ for (const s of services) {
147
+ for (const dep of s.needs ?? []) {
148
+ if (!byName.has(dep)) throw new Error(`service ${s.name}: needs "${dep}" which is not defined`);
149
+ }
150
+ }
151
+ // Topological order via DFS; a back-edge is a cycle.
152
+ const order: string[] = [];
153
+ const state = new Map<string, "visiting" | "done">();
154
+ const visit = (name: string, trail: string[]): void => {
155
+ const st = state.get(name);
156
+ if (st === "done") return;
157
+ if (st === "visiting") throw new Error(`service dependency cycle: ${[...trail, name].join(" -> ")}`);
158
+ state.set(name, "visiting");
159
+ for (const dep of byName.get(name)!.needs ?? []) visit(dep, [...trail, name]);
160
+ state.set(name, "done");
161
+ order.push(name);
162
+ };
163
+ for (const s of services) visit(s.name, []);
164
+ return order;
165
+ }
166
+
167
+ /** Comparable service config (the fields the reconcile diffs on). Pure. */
168
+ export function serviceConfigEqual(a: Partial<ServiceSpec>, b: Partial<ServiceSpec>): boolean {
169
+ const norm = (s: Partial<ServiceSpec>): string =>
170
+ JSON.stringify({
171
+ cmd: s.cmd ?? "",
172
+ args: s.args ?? [],
173
+ env: s.env ?? {},
174
+ dir: s.dir ?? "",
175
+ needs: s.needs ?? [],
176
+ http_port: s.http_port ?? null,
177
+ });
178
+ return norm(a) === norm(b);
179
+ }
180
+
181
+ const servicesUrl = (base: string, id: string): string =>
182
+ `${base}/v1/sprites/${encodeURIComponent(id)}/services`;
183
+ const serviceUrl = (base: string, id: string, svc: string): string =>
184
+ `${servicesUrl(base, id)}/${encodeURIComponent(svc)}`;
185
+
186
+ function serviceBody(s: ServiceSpec): Record<string, unknown> {
187
+ return {
188
+ cmd: s.cmd,
189
+ ...(s.args ? { args: s.args } : {}),
190
+ ...(s.env ? { env: s.env } : {}),
191
+ ...(s.dir ? { dir: s.dir } : {}),
192
+ ...(s.needs ? { needs: s.needs } : {}),
193
+ ...(s.http_port !== undefined ? { http_port: s.http_port } : {}),
194
+ };
195
+ }
196
+
197
+ /**
198
+ * Reconcile a Sprite's background services (additive + update). Lists the live
199
+ * services, then `PUT`s each desired service that is new or changed
200
+ * (create-or-update by name); when `start` is set, `POST .../start`s them in
201
+ * dependency order. Converged services are skipped. Owned-only prune is out of
202
+ * scope (no delete in the Services REST surface).
203
+ */
204
+ export async function spriteApplyServices(
205
+ args: SpriteApplyServicesArgs,
206
+ signal?: AbortSignal,
207
+ http: SpritesHttp = defaultSpritesHttp(args.token),
208
+ ): Promise<SpriteApplyServicesResult> {
209
+ const startOrder = validateServices(args.services);
210
+ const base = resolveSpritesEndpoint(args);
211
+ const byName = new Map(args.services.map((s) => [s.name, s]));
212
+
213
+ // Live services, keyed by name, for the create-vs-update diff.
214
+ const listRes = await http("GET", servicesUrl(base, args.id), undefined, undefined, signal);
215
+ if (listRes.status >= 300) throw new Error(`sprite ${args.id} list services failed (${listRes.status}): ${listRes.text}`);
216
+ const liveList = safeJson(listRes.text);
217
+ const live = new Map<string, Partial<ServiceSpec>>();
218
+ if (Array.isArray(liveList)) {
219
+ for (const s of liveList as Array<Partial<ServiceSpec> & { name?: string }>) {
220
+ if (s.name) live.set(s.name, s);
221
+ }
222
+ }
223
+
224
+ const applied: string[] = [];
225
+ for (const s of args.services) {
226
+ const cur = live.get(s.name);
227
+ if (cur && serviceConfigEqual(cur, s)) continue; // already converged
228
+ const res = await http("PUT", serviceUrl(base, args.id, s.name), serviceBody(s), undefined, signal);
229
+ if (res.status >= 300) throw new Error(`sprite ${args.id} apply service ${s.name} failed (${res.status}): ${res.text}`);
230
+ applied.push(s.name);
231
+ }
232
+ console.log(`services: sprite/${args.id} applied ${applied.length}/${args.services.length} (${base})`);
233
+
234
+ const started: string[] = [];
235
+ if (args.start) {
236
+ for (const name of startOrder) {
237
+ if (!byName.has(name)) continue;
238
+ const res = await http("POST", `${serviceUrl(base, args.id, name)}/start`, undefined, undefined, signal);
239
+ if (res.status >= 300) throw new Error(`sprite ${args.id} start service ${name} failed (${res.status}): ${res.text}`);
240
+ started.push(name);
241
+ }
242
+ console.log(`services: sprite/${args.id} started ${started.length} in dependency order`);
243
+ }
244
+
245
+ return { applied, started };
246
+ }
@@ -0,0 +1,136 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from "vitest";
2
+ import {
3
+ loadActivities,
4
+ runOpLocally,
5
+ phase,
6
+ spriteCreate,
7
+ spriteExec,
8
+ spriteWriteFile,
9
+ spriteReadFile,
10
+ type ActivityFn,
11
+ type ActivityProfile,
12
+ type OpConfig,
13
+ } from "@intentius/chant/op";
14
+ import { createSpritesFake } from "./sprites-fake";
15
+ import { spriteCreate as createImpl } from "./sprites";
16
+ import {
17
+ spriteWriteFile as writeImpl,
18
+ spriteReadFile as readImpl,
19
+ spriteListDir as listImpl,
20
+ spriteRemove as removeImpl,
21
+ spriteFsUrl,
22
+ } from "./sprite-fs";
23
+
24
+ // Filesystem activities (#848) end-to-end against the in-process fake (S7) — no
25
+ // Docker, runs in CI. Direct-impl calls cover the return values; an Op-level
26
+ // stage → process → collect proves they resolve by name and compose.
27
+
28
+ const PROFILES: Record<string, ActivityProfile> = {
29
+ longInfra: { startToCloseTimeout: "5m", retry: { maximumAttempts: 3, initialInterval: "1ms", backoffCoefficient: 1 } },
30
+ fastIdempotent: { startToCloseTimeout: "5m", retry: { maximumAttempts: 2, initialInterval: "1ms", backoffCoefficient: 1 } },
31
+ };
32
+
33
+ let fake: { url: string; close(): Promise<void> };
34
+ let activities: Map<string, ActivityFn>;
35
+ let prevBaseUrl: string | undefined;
36
+
37
+ beforeAll(async () => {
38
+ fake = await createSpritesFake();
39
+ prevBaseUrl = process.env.SPRITES_BASE_URL;
40
+ process.env.SPRITES_BASE_URL = fake.url;
41
+ activities = await loadActivities(["fly"]);
42
+ });
43
+
44
+ afterAll(async () => {
45
+ if (prevBaseUrl === undefined) delete process.env.SPRITES_BASE_URL;
46
+ else process.env.SPRITES_BASE_URL = prevBaseUrl;
47
+ await fake?.close();
48
+ });
49
+
50
+ async function inspect(id: string): Promise<{ fs: Record<string, string> }> {
51
+ const res = await fetch(`${fake.url}/v1/sprites/${id}`);
52
+ return (await res.json()) as { fs: Record<string, string> };
53
+ }
54
+
55
+ describe("spriteFsUrl (pure)", () => {
56
+ test("builds a query string, dropping unset params", () => {
57
+ expect(spriteFsUrl("http://h", "s 1", "write", { path: "/a", mkdir: true, mode: undefined, workingDir: "" })).toBe(
58
+ "http://h/v1/sprites/s%201/fs/write?path=%2Fa&mkdir=true",
59
+ );
60
+ });
61
+ test("no params → no query", () => {
62
+ expect(spriteFsUrl("http://h", "s", "read", {})).toBe("http://h/v1/sprites/s/fs/read");
63
+ });
64
+ });
65
+
66
+ describe("fs activities resolve by name", () => {
67
+ test("loadActivities([\"fly\"]) exposes the four fs activities", () => {
68
+ for (const fn of ["spriteWriteFile", "spriteReadFile", "spriteListDir", "spriteRemove"]) {
69
+ expect(typeof activities.get(fn)).toBe("function");
70
+ }
71
+ });
72
+ });
73
+
74
+ describe("write → read → list → remove round-trip", () => {
75
+ test("round-trips against the fake, remove is idempotent", async () => {
76
+ await createImpl({ name: "fs-1", endpoint: fake.url });
77
+
78
+ await writeImpl({ id: "fs-1", path: "/work/input", content: "hello", endpoint: fake.url });
79
+ expect((await readImpl({ id: "fs-1", path: "/work/input", endpoint: fake.url })).content).toBe("hello");
80
+
81
+ await writeImpl({ id: "fs-1", path: "/work/notes/a.txt", content: "x", endpoint: fake.url });
82
+ const list = await listImpl({ id: "fs-1", path: "/work", endpoint: fake.url });
83
+ expect(list).toEqual(
84
+ expect.arrayContaining([
85
+ { name: "notes", type: "dir" },
86
+ { name: "input", type: "file", size: 5 },
87
+ ]),
88
+ );
89
+
90
+ await removeImpl({ id: "fs-1", path: "/work/input", endpoint: fake.url });
91
+ await expect(readImpl({ id: "fs-1", path: "/work/input", endpoint: fake.url })).rejects.toThrow(/not found/);
92
+ // Idempotent: removing an already-gone path is a no-op (404 tolerated).
93
+ await expect(removeImpl({ id: "fs-1", path: "/work/input", endpoint: fake.url })).resolves.toBeDefined();
94
+ });
95
+
96
+ test("reading a missing file throws not-found", async () => {
97
+ await createImpl({ name: "fs-2", endpoint: fake.url });
98
+ await expect(readImpl({ id: "fs-2", path: "/nope", endpoint: fake.url })).rejects.toThrow(/not found/);
99
+ });
100
+
101
+ test("recursive remove clears a whole subtree", async () => {
102
+ await createImpl({ name: "fs-3", endpoint: fake.url });
103
+ await writeImpl({ id: "fs-3", path: "/d/a", content: "1", endpoint: fake.url });
104
+ await writeImpl({ id: "fs-3", path: "/d/sub/b", content: "2", endpoint: fake.url });
105
+ await removeImpl({ id: "fs-3", path: "/d", recursive: true, endpoint: fake.url });
106
+ expect(await listImpl({ id: "fs-3", path: "/d", endpoint: fake.url })).toEqual([]);
107
+ });
108
+ });
109
+
110
+ describe("Op-level stage → process → collect (the example flow)", () => {
111
+ test("write input, exec copies it, read output — runs green by-name", async () => {
112
+ const op: OpConfig = {
113
+ name: "fs-agent-task",
114
+ overview: "stage an input file, process it, collect the result",
115
+ taskQueue: "sprites",
116
+ phases: [
117
+ phase("Create", [spriteCreate({ name: "fs-op-1" })]),
118
+ phase("Stage", [spriteWriteFile({ id: "fs-op-1", path: "/work/input", content: "hello" })]),
119
+ phase("Run", [spriteExec({ id: "fs-op-1", cmd: "cat /work/input > /work/output" })]),
120
+ phase("Collect", [spriteReadFile({ id: "fs-op-1", path: "/work/output" })]),
121
+ ],
122
+ };
123
+ const result = await runOpLocally(op, activities, PROFILES);
124
+ expect(result.ok).toBe(true);
125
+ expect(result.records.map((r) => r.fn)).toEqual([
126
+ "spriteCreate",
127
+ "spriteWriteFile",
128
+ "spriteExec",
129
+ "spriteReadFile",
130
+ ]);
131
+ expect(result.records.every((r) => r.status === "ok")).toBe(true);
132
+
133
+ const state = await inspect("fs-op-1");
134
+ expect(state.fs["/work/output"]).toBe("hello");
135
+ });
136
+ });
@@ -0,0 +1,217 @@
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 mirror `sprites.ts` — an explicit `endpoint` /
12
+ * `token` wins, then `SPRITES_BASE_URL` / `SPRITES_API_TOKEN`.
13
+ */
14
+
15
+ import { resolveSpritesEndpoint } from "./sprites";
16
+
17
+ function safeJson(text: string): unknown {
18
+ try {
19
+ return JSON.parse(text);
20
+ } catch {
21
+ return undefined;
22
+ }
23
+ }
24
+
25
+ // ── Pure URL builders (unit-testable) ──────────────────────────────────────────
26
+
27
+ /**
28
+ * Build a `/v1/sprites/{id}/fs/{op}?...` URL. `params` values that are undefined
29
+ * or empty are dropped so the query only carries what the caller set. Pure.
30
+ */
31
+ export function spriteFsUrl(
32
+ base: string,
33
+ id: string,
34
+ op: "read" | "write" | "list" | "delete",
35
+ params: Record<string, string | boolean | undefined>,
36
+ ): string {
37
+ const q = new URLSearchParams();
38
+ for (const [k, v] of Object.entries(params)) {
39
+ if (v === undefined || v === "") continue;
40
+ q.set(k, typeof v === "boolean" ? String(v) : v);
41
+ }
42
+ const qs = q.toString();
43
+ return `${base}/v1/sprites/${encodeURIComponent(id)}/fs/${op}${qs ? `?${qs}` : ""}`;
44
+ }
45
+
46
+ // ── Raw HTTP client (raw string bodies, mirrors defaultSpritesHttp) ─────────────
47
+
48
+ /**
49
+ * Injectable raw HTTP client — like `SpritesHttp` but the body is sent verbatim
50
+ * (a file's bytes as a string), not JSON-encoded, and the response `text` is the
51
+ * raw body. Tests inject a fake; the default hits `fetch`.
52
+ */
53
+ export type SpritesRawHttp = (
54
+ method: string,
55
+ url: string,
56
+ body?: string,
57
+ headers?: Record<string, string>,
58
+ signal?: AbortSignal,
59
+ ) => Promise<{ status: number; text: string }>;
60
+
61
+ /**
62
+ * Default `fetch`-based raw client. Sends the body as-is with an
63
+ * `application/octet-stream` content-type and `Authorization: Bearer <token>`
64
+ * when a token is set (real Sprites); the fake ignores the token. The token
65
+ * defaults to `SPRITES_API_TOKEN` at call time. `fetchImpl` is injectable.
66
+ */
67
+ export function defaultSpritesRawHttp(token?: string, fetchImpl: typeof fetch = fetch): SpritesRawHttp {
68
+ return async (method, url, body, headers, signal) => {
69
+ const h: Record<string, string> = { ...headers };
70
+ if (body !== undefined) h["content-type"] = "application/octet-stream";
71
+ const tok = token ?? process.env.SPRITES_API_TOKEN;
72
+ if (tok) h["authorization"] = `Bearer ${tok}`;
73
+ const res = await fetchImpl(url, {
74
+ method,
75
+ headers: Object.keys(h).length ? h : undefined,
76
+ body,
77
+ signal,
78
+ });
79
+ return { status: res.status, text: await res.text() };
80
+ };
81
+ }
82
+
83
+ // ── Activity contracts ──────────────────────────────────────────────────────────
84
+
85
+ export interface SpriteWriteFileArgs {
86
+ /** Target sprite id (the `name` passed to `spriteCreate`). */
87
+ id: string;
88
+ /** Absolute path (or relative to `workingDir`) to write. */
89
+ path: string;
90
+ /** File contents. */
91
+ content: string;
92
+ /** Octal file mode, e.g. `"0644"`. */
93
+ mode?: string;
94
+ /** Create missing parent directories. */
95
+ mkdir?: boolean;
96
+ /** Base directory for a relative `path`. */
97
+ workingDir?: string;
98
+ endpoint?: string;
99
+ token?: string;
100
+ }
101
+
102
+ export interface SpriteReadFileArgs {
103
+ id: string;
104
+ path: string;
105
+ workingDir?: string;
106
+ endpoint?: string;
107
+ token?: string;
108
+ }
109
+
110
+ export interface SpriteReadFileResult {
111
+ content: string;
112
+ }
113
+
114
+ export interface SpriteListDirArgs {
115
+ id: string;
116
+ path: string;
117
+ workingDir?: string;
118
+ endpoint?: string;
119
+ token?: string;
120
+ }
121
+
122
+ /** One directory entry. `type` is `file` or `dir`. */
123
+ export interface SpriteDirEntry {
124
+ name: string;
125
+ type: "file" | "dir";
126
+ size?: number;
127
+ }
128
+
129
+ export interface SpriteRemoveArgs {
130
+ id: string;
131
+ path: string;
132
+ /** Remove a directory and its contents. */
133
+ recursive?: boolean;
134
+ /** Perform the delete as root. */
135
+ asRoot?: boolean;
136
+ workingDir?: string;
137
+ endpoint?: string;
138
+ token?: string;
139
+ }
140
+
141
+ // ── Activities (ActivityFn: (args, signal?) => Promise<unknown>) ──────────────
142
+
143
+ /** Write a file into the sprite. `PUT /v1/sprites/{id}/fs/write` with raw body. */
144
+ export async function spriteWriteFile(
145
+ args: SpriteWriteFileArgs,
146
+ signal?: AbortSignal,
147
+ http: SpritesRawHttp = defaultSpritesRawHttp(args.token),
148
+ ): Promise<Record<string, never>> {
149
+ const base = resolveSpritesEndpoint(args);
150
+ const url = spriteFsUrl(base, args.id, "write", {
151
+ path: args.path,
152
+ mode: args.mode,
153
+ mkdir: args.mkdir,
154
+ workingDir: args.workingDir,
155
+ });
156
+ const res = await http("PUT", url, args.content, undefined, signal);
157
+ if (res.status >= 300) {
158
+ throw new Error(`sprite ${args.id} write ${args.path} failed (${res.status}): ${res.text}`);
159
+ }
160
+ console.log(`wrote: sprite/${args.id}:${args.path} (${args.content.length}b)`);
161
+ return {};
162
+ }
163
+
164
+ /** Read a file from the sprite. `GET /v1/sprites/{id}/fs/read` → raw body. */
165
+ export async function spriteReadFile(
166
+ args: SpriteReadFileArgs,
167
+ signal?: AbortSignal,
168
+ http: SpritesRawHttp = defaultSpritesRawHttp(args.token),
169
+ ): Promise<SpriteReadFileResult> {
170
+ const base = resolveSpritesEndpoint(args);
171
+ const url = spriteFsUrl(base, args.id, "read", { path: args.path, workingDir: args.workingDir });
172
+ const res = await http("GET", url, undefined, undefined, signal);
173
+ if (res.status === 404) throw new Error(`sprite ${args.id} read ${args.path}: not found`);
174
+ if (res.status >= 300) throw new Error(`sprite ${args.id} read ${args.path} failed (${res.status}): ${res.text}`);
175
+ return { content: res.text };
176
+ }
177
+
178
+ /** List a directory in the sprite. `GET /v1/sprites/{id}/fs/list` → entry array. */
179
+ export async function spriteListDir(
180
+ args: SpriteListDirArgs,
181
+ signal?: AbortSignal,
182
+ http: SpritesRawHttp = defaultSpritesRawHttp(args.token),
183
+ ): Promise<SpriteDirEntry[]> {
184
+ const base = resolveSpritesEndpoint(args);
185
+ const url = spriteFsUrl(base, args.id, "list", { path: args.path, workingDir: args.workingDir });
186
+ const res = await http("GET", url, undefined, undefined, signal);
187
+ if (res.status >= 300) throw new Error(`sprite ${args.id} list ${args.path} failed (${res.status}): ${res.text}`);
188
+ const parsed = safeJson(res.text);
189
+ // Accept a bare array or a `{ entries: [...] }` envelope.
190
+ if (Array.isArray(parsed)) return parsed as SpriteDirEntry[];
191
+ const entries = (parsed as { entries?: unknown } | undefined)?.entries;
192
+ return Array.isArray(entries) ? (entries as SpriteDirEntry[]) : [];
193
+ }
194
+
195
+ /**
196
+ * Remove a path in the sprite. `DELETE /v1/sprites/{id}/fs/delete`. Idempotent:
197
+ * a 404 (already gone) is a no-op, matching `rm -f` and the destroy activity.
198
+ */
199
+ export async function spriteRemove(
200
+ args: SpriteRemoveArgs,
201
+ signal?: AbortSignal,
202
+ http: SpritesRawHttp = defaultSpritesRawHttp(args.token),
203
+ ): Promise<Record<string, never>> {
204
+ const base = resolveSpritesEndpoint(args);
205
+ const url = spriteFsUrl(base, args.id, "delete", {
206
+ path: args.path,
207
+ recursive: args.recursive,
208
+ asRoot: args.asRoot,
209
+ workingDir: args.workingDir,
210
+ });
211
+ const res = await http("DELETE", url, undefined, undefined, signal);
212
+ if (res.status >= 300 && res.status !== 404) {
213
+ throw new Error(`sprite ${args.id} remove ${args.path} failed (${res.status}): ${res.text}`);
214
+ }
215
+ console.log(`removed: sprite/${args.id}:${args.path}`);
216
+ return {};
217
+ }