@gjermundgaraba/clankerbox-sdk 0.15.2 → 0.15.3

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.
@@ -297,6 +297,11 @@ interface Interface {
297
297
  readonly capacity: Effect.Effect<Gathered<Limit>>;
298
298
  readonly machine: (id: string) => Effect.Effect<Machine, ClankerboxError>;
299
299
  readonly checkpoint: (id: string) => Effect.Effect<Checkpoint, ClankerboxError>;
300
+ /**
301
+ * The host a create of `base` goes to: the options' host, or else the first host in the list
302
+ * that offers the base. It is read either way, so a caller knows its runtime before it creates.
303
+ */
304
+ readonly place: (base: string, options?: CreateOptions) => Effect.Effect<Host, ClankerboxError>;
300
305
  /**
301
306
  * Creates a machine named `name` on the options' host, or else on the first host in the
302
307
  * list that offers the spec's base.
@@ -320,4 +325,4 @@ declare class Client extends Client_base {}
320
325
  */
321
326
  declare const make: (hosts: ReadonlyArray<HostEntry>, options?: Options) => Effect.Effect<Interface, Invalid, HttpClient.HttpClient>;
322
327
  //#endregion
323
- export { Precondition as A, ConflictKind as C, Internal as D, HostErrorTag as E, hostErrors as M, Invalid as O, Conflict as S, HostError as T, SshLogin as _, ActionName as a, Capacity as b, CreateRequest as c, Machine as d, MachineSpec as f, SshEndpoint as g, Setup as h, client_d_exports as i, Unavailable as j, NotFound as k, Host as l, Runtime as m, HostEntry as n, ActionRecord as o, Profile as p, Options as r, Checkpoint as s, Client as t, Limit as u, release as v, ErrorTag as w, ClankerboxError as x, version as y };
328
+ export { Invalid as A, ClankerboxError as C, HostError as D, ErrorTag as E, Precondition as M, Unavailable as N, HostErrorTag as O, hostErrors as P, Capacity as S, ConflictKind as T, Setup as _, Options as a, release as b, ActionRecord as c, Host as d, Limit as f, Runtime as g, Profile as h, Interface as i, NotFound as j, Internal as k, Checkpoint as l, MachineSpec as m, CreateOptions as n, client_d_exports as o, Machine as p, HostEntry as r, ActionName as s, Client as t, CreateRequest as u, SshEndpoint as v, Conflict as w, version as x, SshLogin as y };
@@ -129,7 +129,7 @@ const formatId = (host, name) => {
129
129
  };
130
130
  //#endregion
131
131
  //#region package.json
132
- var version$1 = "0.15.2";
132
+ var version$1 = "0.15.3";
133
133
  //#endregion
134
134
  //#region src/resources.ts
135
135
  /** The resources a host reports: machines, checkpoints and the host itself. */
@@ -598,7 +598,7 @@ const make = (hosts, options) => Effect.gen(function* () {
598
598
  * silent host after it delays nothing. With no winner the create is `Unavailable` only if
599
599
  * a host gave no reply; otherwise asking again changes nothing, and it is `Precondition`.
600
600
  */
601
- const place = (base) => Effect.gen(function* () {
601
+ const offering = (base) => Effect.gen(function* () {
602
602
  const asked = yield* Effect.forEach(routed, (route) => Effect.map(Effect.forkScoped(Effect.result(readHost(route))), (fiber) => ({
603
603
  host: route.entry.id,
604
604
  fiber
@@ -611,7 +611,7 @@ const make = (hosts, options) => Effect.gen(function* () {
611
611
  if (Result.isFailure(result)) {
612
612
  noReply ||= Predicate.isTagged(result.failure, "Unavailable");
613
613
  misses.push(`${host} failed: ${result.failure.message}`);
614
- } else if (result.success.bases.includes(base)) return host;
614
+ } else if (result.success.bases.includes(base)) return result.success;
615
615
  else {
616
616
  const { bases } = result.success;
617
617
  misses.push(`${host} offers ${bases.length > 0 ? bases.join(", ") : "no bases"}`);
@@ -626,8 +626,9 @@ const make = (hosts, options) => Effect.gen(function* () {
626
626
  const create = (name, spec, options) => Effect.gen(function* () {
627
627
  yield* parseName(name);
628
628
  if (options?.host !== void 0) return yield* createOn(options.host, name, spec);
629
- return yield* createOn(yield* place(spec.base), name, spec);
629
+ return yield* createOn((yield* offering(spec.base)).id, name, spec);
630
630
  });
631
+ const place = (base, options) => options?.host === void 0 ? offering(base) : Effect.flatMap(route(options.host), readHost);
631
632
  /** The ID a call that makes a resource on `source`'s host gives it. */
632
633
  const sibling = (source, name) => Effect.flatMap(parseId(source), ({ host }) => formatId(host, name));
633
634
  return {
@@ -637,6 +638,7 @@ const make = (hosts, options) => Effect.gen(function* () {
637
638
  capacity: gather((route) => ask(route, actions.getCapacity, "get capacity", (api) => api.getCapacity())),
638
639
  machine: (id) => byId(id, actions.getMachine, `get ${id}`, (api) => api.getMachine({ id })),
639
640
  checkpoint: (id) => byId(id, actions.getCheckpoint, `get ${id}`, (api) => api.getCheckpoint({ id })),
641
+ place,
640
642
  create,
641
643
  start: (id) => byId(id, actions.startMachine, `start ${id}`, (api) => api.startMachine({ id })),
642
644
  stop: (id) => byId(id, actions.stopMachine, `stop ${id}`, (api) => api.stopMachine({ id })),
package/dist/index.d.mts CHANGED
@@ -1,8 +1,7 @@
1
- import { A as Precondition, C as ConflictKind, D as Internal, E as HostErrorTag, M as hostErrors, O as Invalid, S as Conflict, T as HostError, _ as SshLogin, a as ActionName, b as Capacity, c as CreateRequest, d as Machine, f as MachineSpec, g as SshEndpoint, h as Setup, i as client_d_exports, j as Unavailable, k as NotFound, l as Host, m as Runtime, o as ActionRecord, p as Profile, s as Checkpoint, u as Limit, v as release, w as ErrorTag, x as ClankerboxError, y as version } from "./client-SX6jTU1R.mjs";
1
+ import { A as Invalid, C as ClankerboxError, D as HostError, E as ErrorTag, M as Precondition, N as Unavailable, O as HostErrorTag, P as hostErrors, S as Capacity, T as ConflictKind, _ as Setup, b as release, c as ActionRecord, d as Host, f as Limit, g as Runtime, h as Profile, i as Interface, j as NotFound, k as Internal, l as Checkpoint, m as MachineSpec, n as CreateOptions, o as client_d_exports, p as Machine, s as ActionName, u as CreateRequest, v as SshEndpoint, w as Conflict, x as version, y as SshLogin } from "./client-Cn1bj4It.mjs";
2
2
  import * as Action from "@gjermundgaraba/effect-actions/Action";
3
3
  import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
4
4
  import { Duration, Effect, FileSystem, Path, PlatformError, Schema } from "effect";
5
- import { ChildProcessSpawner } from "effect/process";
6
5
  //#region src/api.d.ts
7
6
  /**
8
7
  * How long a client waits for a read's reply from one host, unless it sets its own. A host
@@ -307,6 +306,54 @@ export declare const parseId: (id: string) => Effect.Effect<IdParts, Invalid>;
307
306
  export declare const parseName: (name: string) => Effect.Effect<string, Invalid>;
308
307
  /** Joins a host ID and a name into the resource's ID, checking both parts and the whole. */
309
308
  export declare const formatId: (host: string, name: string) => Effect.Effect<string, Invalid>;
309
+ declare namespace prebuild_d_exports {
310
+ export { Built, Created, build, create, nameOf };
311
+ }
312
+ /**
313
+ * The name of the spec's prebuild on a host: a digest of the spec's label, base and sizes, the
314
+ * setup script as sent, and the host's runtime version. A `ram` checkpoint restores only under
315
+ * the release that saved it, so after a runtime bump a create misses the old prebuild instead
316
+ * of failing to restore it. The setup's timeout is no part of it, and neither is the base's
317
+ * image: a host only names it. The script holds whatever secrets a recipe carries, and the name
318
+ * 12 hex characters of a digest of it.
319
+ */
320
+ declare const nameOf: (spec: MachineSpec, host: Pick<Host, "runtimeVersion">) => Effect.Effect<string>;
321
+ interface Created {
322
+ readonly machine: Machine;
323
+ /** The prebuild the machine was restored from; without one, its setup ran. */
324
+ readonly prebuild?: Checkpoint | undefined;
325
+ }
326
+ /**
327
+ * Creates a machine named `name` as `client.create` does, but restores it from the spec's
328
+ * prebuild when the host it lands on has one whose capture is done. A spec without a setup has
329
+ * none. Placement is the create's own, and a prebuild stays on the host that made it, so a spec
330
+ * placed on another host runs its setup. A failed restore is the reply.
331
+ */
332
+ declare const create: (client: Interface, name: string, spec: MachineSpec, options?: CreateOptions) => Effect.Effect<Created, ClankerboxError>;
333
+ interface Built {
334
+ readonly checkpoint: Checkpoint;
335
+ /** Whether this call built it; false when it was already there. */
336
+ readonly built: boolean;
337
+ /**
338
+ * The ID of the machine that built it, when its delete failed: it may still run, holding its
339
+ * RAM, until it is deleted.
340
+ */
341
+ readonly leftover?: string | undefined;
342
+ }
343
+ /**
344
+ * Builds the spec's prebuild on the host a create of it lands on: a machine of its own runs the
345
+ * setup, is captured under the prebuild's name, and is deleted. smolvm captures a running
346
+ * machine, RAM included, and boat a running one too; Tart only a stopped one, so its machine is
347
+ * stopped first. A prebuild whose capture is done is the answer as it is; one that failed or is
348
+ * still captured is refused.
349
+ *
350
+ * Each build's machine has a random name of its own, and the host takes a checkpoint name once,
351
+ * so of two builds of one spec at once both run the setup and the second's capture is
352
+ * `Conflict`: neither touches the other's machine or prebuild. A build deletes its machine
353
+ * however it ends. If that fails after a failure, the first failure is the reply; after a
354
+ * capture, the prebuild is made, and the machine is the reply's `leftover`.
355
+ */
356
+ declare const build: (client: Interface, spec: MachineSpec, options?: CreateOptions) => Effect.Effect<Built, ClankerboxError>;
310
357
  //#endregion
311
358
  //#region src/profile.d.ts
312
359
  /** A profile file as read: its fields, with `setup.path` resolved, and its name as `label`. */
@@ -322,13 +369,17 @@ export declare const loadProfile: (file: string) => Effect.Effect<LoadedProfile,
322
369
  //#endregion
323
370
  //#region src/setup.d.ts
324
371
  /**
325
- * Packs a recipe directory into one self-extracting script: a base64 tar that unpacks into a
326
- * temporary directory, then runs `setup.sh` from there. `COPYFILE_DISABLE=1` and
327
- * `--no-xattrs` keep macOS `tar` from adding `._*` files and provenance headers.
372
+ * Packs a recipe directory into one self-extracting script, which makes each of its entries
373
+ * with plain shell: a directory with `mkdir`, a symlink with `ln -s` as it is, and a file from
374
+ * base64, made executable when any of its execute bits is set. A symlink is never walked into,
375
+ * so nothing outside the recipe is packed, and in the guest nothing is written through one.
376
+ * Of a mode only the execute bit is kept, as git keeps it, so a checkout's umask changes
377
+ * nothing. Entries go in sorted order and nothing records a time or an owner, so the same
378
+ * files pack into the same script: a prebuild is named after it.
328
379
  */
329
- export declare const packRecipe: (directory: string) => Effect.Effect<string, Invalid, FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner>;
380
+ export declare const packRecipe: (directory: string) => Effect.Effect<string, Invalid, FileSystem.FileSystem>;
330
381
  /** Reads a setup: a script file's text, or a recipe directory packed into one script. */
331
- export declare const readSetup: (path: string) => Effect.Effect<string, Invalid, FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner>;
382
+ export declare const readSetup: (path: string) => Effect.Effect<string, Invalid, FileSystem.FileSystem>;
332
383
  //#endregion
333
384
  //#region src/text.d.ts
334
385
  /**
@@ -347,4 +398,4 @@ export declare const describeIssues: (error: Schema.SchemaError, names?: Readonl
347
398
  */
348
399
  export declare const describeFileError: ({ reason }: PlatformError.PlatformError) => string;
349
400
  //#endregion
350
- export { ActionName, ActionRecord, Capacity, Checkpoint, ClankerboxError, client_d_exports as Client, Conflict, ConflictKind, CreateRequest, ErrorTag, Host, HostError, HostErrorTag, Internal, Invalid, Limit, Machine, MachineSpec, NotFound, Precondition, Profile, Runtime, Setup, SshEndpoint, SshLogin, Unavailable, hostErrors, release, version };
401
+ export { ActionName, ActionRecord, Capacity, Checkpoint, ClankerboxError, client_d_exports as Client, Conflict, ConflictKind, CreateRequest, ErrorTag, Host, HostError, HostErrorTag, Internal, Invalid, Limit, Machine, MachineSpec, NotFound, prebuild_d_exports as Prebuild, Precondition, Profile, Runtime, Setup, SshEndpoint, SshLogin, Unavailable, hostErrors, release, version };
package/dist/index.mjs CHANGED
@@ -1,6 +1,105 @@
1
- import { A as parseName, B as Unavailable, C as release, D as Name, E as Id, F as HostErrorTag, I as Internal, L as Invalid, M as Conflict, N as ConflictKind, O as formatId, P as ErrorTag, R as NotFound, S as SshLogin, T as HostId, V as hostErrors, _ as MachineSpec, a as describeIssues, b as Setup, c as readTimeout, d as ActionRecord, f as Checkpoint, g as Machine, h as Limit, i as describeFileError, j as Capacity, k as parseId, l as versionHeader, m as Host, n as client_exports, o as formatMib, p as CreateRequest, s as Http, u as ActionName, v as Profile, w as version, x as SshEndpoint, y as Runtime, z as Precondition } from "./client-cFk3r2_1.mjs";
2
- import { Effect, FileSystem, Path, Predicate, Schema, Stream } from "effect";
3
- import { ChildProcess, ChildProcessSpawner } from "effect/process";
1
+ import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
2
+ import { A as parseName, B as Unavailable, C as release, D as Name, E as Id, F as HostErrorTag, I as Internal, L as Invalid, M as Conflict, N as ConflictKind, O as formatId, P as ErrorTag, R as NotFound, S as SshLogin, T as HostId, V as hostErrors, _ as MachineSpec, a as describeIssues, b as Setup, c as readTimeout, d as ActionRecord, f as Checkpoint, g as Machine, h as Limit, i as describeFileError, j as Capacity, k as parseId, l as versionHeader, m as Host, n as client_exports, o as formatMib, p as CreateRequest, s as Http, u as ActionName, v as Profile, w as version, x as SshEndpoint, y as Runtime, z as Precondition } from "./client-CtI-rSJQ.mjs";
3
+ import { Effect, FileSystem, Option, Path, Predicate, Schema } from "effect";
4
+ //#region src/prebuild.ts
5
+ /**
6
+ * Prebuilds: a checkpoint of a machine whose setup has run, which a create of the same spec
7
+ * restores instead of running the setup again. They are the client's alone. A prebuild is an
8
+ * ordinary checkpoint under a name derived from what it is of, so a host stores no profile and
9
+ * knows nothing of them, and a create finds its prebuild with one read by ID. A prebuild never
10
+ * changes and nothing here deletes one: building anew is a `checkpoint delete` first.
11
+ */
12
+ var prebuild_exports = /* @__PURE__ */ __exportAll({
13
+ build: () => build,
14
+ create: () => create,
15
+ nameOf: () => nameOf
16
+ });
17
+ const prefix = "prebuilt-";
18
+ /** How much of the digest a prebuild's name carries. */
19
+ const digestDigits = 12;
20
+ /**
21
+ * How many random bytes a builder's name carries: 64 bits, so no two builds draw one name. With
22
+ * `prebuilding-`, it is 28 characters, inside the 29 a 32-character host ID and its `_` leave of
23
+ * an ID's 62.
24
+ */
25
+ const builderBytes = 8;
26
+ const hex = (bytes) => Array.from(new Uint8Array(bytes), (byte) => byte.toString(16).padStart(2, "0")).join("");
27
+ /**
28
+ * The name of the spec's prebuild on a host: a digest of the spec's label, base and sizes, the
29
+ * setup script as sent, and the host's runtime version. A `ram` checkpoint restores only under
30
+ * the release that saved it, so after a runtime bump a create misses the old prebuild instead
31
+ * of failing to restore it. The setup's timeout is no part of it, and neither is the base's
32
+ * image: a host only names it. The script holds whatever secrets a recipe carries, and the name
33
+ * 12 hex characters of a digest of it.
34
+ */
35
+ const nameOf = (spec, host) => Effect.promise(() => globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify([
36
+ spec.profile ?? null,
37
+ spec.base,
38
+ spec.cpu,
39
+ spec.ramMib,
40
+ spec.diskGib,
41
+ spec.setup?.script ?? null,
42
+ host.runtimeVersion
43
+ ])))).pipe(Effect.map((digest) => `${prefix}${hex(digest).slice(0, digestDigits)}`));
44
+ /** The checkpoint `id`, if the host has it. */
45
+ const lookup = (client, id) => client.checkpoint(id).pipe(Effect.map(Option.some), Effect.catchTag("NotFound", () => Effect.succeedNone));
46
+ /**
47
+ * Creates a machine named `name` as `client.create` does, but restores it from the spec's
48
+ * prebuild when the host it lands on has one whose capture is done. A spec without a setup has
49
+ * none. Placement is the create's own, and a prebuild stays on the host that made it, so a spec
50
+ * placed on another host runs its setup. A failed restore is the reply.
51
+ */
52
+ const create = (client, name, spec, options) => Effect.gen(function* () {
53
+ if (spec.setup === void 0) return { machine: yield* client.create(name, spec, options) };
54
+ const host = yield* client.place(spec.base, options);
55
+ const id = yield* formatId(host.id, yield* nameOf(spec, host));
56
+ const found = yield* lookup(client, id);
57
+ if (Option.isSome(found) && found.value.action.status === "done") return {
58
+ machine: yield* client.restore(id, name),
59
+ prebuild: found.value
60
+ };
61
+ return { machine: yield* client.create(name, spec, { host: host.id }) };
62
+ });
63
+ /**
64
+ * Builds the spec's prebuild on the host a create of it lands on: a machine of its own runs the
65
+ * setup, is captured under the prebuild's name, and is deleted. smolvm captures a running
66
+ * machine, RAM included, and boat a running one too; Tart only a stopped one, so its machine is
67
+ * stopped first. A prebuild whose capture is done is the answer as it is; one that failed or is
68
+ * still captured is refused.
69
+ *
70
+ * Each build's machine has a random name of its own, and the host takes a checkpoint name once,
71
+ * so of two builds of one spec at once both run the setup and the second's capture is
72
+ * `Conflict`: neither touches the other's machine or prebuild. A build deletes its machine
73
+ * however it ends. If that fails after a failure, the first failure is the reply; after a
74
+ * capture, the prebuild is made, and the machine is the reply's `leftover`.
75
+ */
76
+ const build = (client, spec, options) => Effect.gen(function* () {
77
+ if (spec.setup === void 0) return yield* new Invalid({ message: "a prebuild needs a setup" });
78
+ const host = yield* client.place(spec.base, options);
79
+ const name = yield* nameOf(spec, host);
80
+ const id = yield* formatId(host.id, name);
81
+ const found = yield* lookup(client, id);
82
+ if (Option.isSome(found)) {
83
+ const { action } = found.value;
84
+ return action.status === "done" ? {
85
+ checkpoint: found.value,
86
+ built: false
87
+ } : yield* new Precondition({ message: `prebuild ${id}'s ${action.name} is ${action.status}; delete it to prebuild again` });
88
+ }
89
+ const builder = `prebuilding-${hex(globalThis.crypto.getRandomValues(new Uint8Array(builderBytes)))}`;
90
+ const builderId = yield* formatId(host.id, builder);
91
+ const removal = Effect.catchTag(client.delete(builderId), "NotFound", () => Effect.void);
92
+ return {
93
+ checkpoint: yield* Effect.gen(function* () {
94
+ yield* client.create(builder, spec, { host: host.id });
95
+ if (host.runtime === "tart") yield* client.stop(builderId);
96
+ return yield* client.capture(builderId, name);
97
+ }).pipe(Effect.onError(() => Effect.ignore(removal))),
98
+ built: true,
99
+ leftover: yield* removal.pipe(Effect.as(void 0), Effect.catch(() => Effect.succeed(builderId)))
100
+ };
101
+ });
102
+ //#endregion
4
103
  //#region src/profile.ts
5
104
  /** Reads a profile file for `create`. Hosts never see a profile. */
6
105
  /** Profile files are JSON, decoded with closed input like every call: unknown keys are refused. */
@@ -39,66 +138,79 @@ const loadProfile = (file) => Effect.gen(function* () {
39
138
  * script. Nothing here logs a script or a packed recipe: a recipe can carry secrets.
40
139
  */
41
140
  const lineLength = 76;
42
- /** base64 has no `_`, so no line of the payload can end the here-document early. */
141
+ /** base64 has no `_`, so no line of a file's payload can end its here-document early. */
43
142
  const terminator = "CLANKERBOX_RECIPE";
44
143
  const wrap = (base64) => {
45
144
  const lines = [];
46
145
  for (let at = 0; at < base64.length; at += lineLength) lines.push(base64.slice(at, at + lineLength));
47
146
  return lines.join("\n");
48
147
  };
148
+ /** A string as one single-quoted shell word, whatever it holds. */
149
+ const quote = (text) => `'${text.replaceAll("'", `'\\''`)}'`;
150
+ /**
151
+ * An entry's path as a command's argument: under `./`, so a name that starts with `-` is never
152
+ * read as an option.
153
+ */
154
+ const argument = (entry) => quote(`./${entry}`);
155
+ /** Any execute bit, which is the one bit of a mode a recipe carries, as git does. */
156
+ const executable = 73;
49
157
  /**
50
- * Pipes the archive into `tar -x` in a fresh directory, so no file of the recipe's can
51
- * collide with the archive, then runs `./setup.sh` from there: its `#!` line picks the
52
- * interpreter, as for a single-file setup, and the unpacker marks it executable whatever mode
53
- * it was packed with. The directory is under `/var/tmp`, as a single-file setup is, which
54
- * nothing mounts `noexec`, and is removed however setup ends. Setup runs as a child
55
- * rather than through `exec`, so the cleanup trap still runs after it. `--no-same-owner`
56
- * keeps the packer's uid off the files when root unpacks them in the guest.
158
+ * Makes the recipe's files in a fresh directory, then runs `./setup.sh` from there: its `#!`
159
+ * line picks the interpreter, as for a single-file setup, and the unpacker marks it executable
160
+ * whatever mode it was packed with. The directory is under `/var/tmp`, as a single-file setup
161
+ * is, which nothing mounts `noexec`, and is removed however setup ends. Setup runs as a child
162
+ * rather than through `exec`, so the cleanup trap still runs after it.
57
163
  */
58
- const selfExtracting = (archive) => [
164
+ const selfExtracting = (steps) => [
59
165
  "#!/bin/sh",
60
166
  "set -eu",
61
167
  "recipe=$(mktemp -d /var/tmp/clankerbox-recipe.XXXXXX)",
62
168
  `trap 'rm -rf "$recipe"' EXIT`,
63
169
  "trap 'exit 1' HUP INT TERM",
64
- `base64 -d <<'${terminator}' | tar -x -f - --no-same-owner -C "$recipe"`,
65
- wrap(archive),
66
- terminator,
67
170
  "cd \"$recipe\"",
171
+ ...steps,
68
172
  "chmod +x setup.sh",
69
173
  "./setup.sh",
70
174
  ""
71
175
  ].join("\n");
72
176
  /**
73
- * Packs a recipe directory into one self-extracting script: a base64 tar that unpacks into a
74
- * temporary directory, then runs `setup.sh` from there. `COPYFILE_DISABLE=1` and
75
- * `--no-xattrs` keep macOS `tar` from adding `._*` files and provenance headers.
177
+ * Packs a recipe directory into one self-extracting script, which makes each of its entries
178
+ * with plain shell: a directory with `mkdir`, a symlink with `ln -s` as it is, and a file from
179
+ * base64, made executable when any of its execute bits is set. A symlink is never walked into,
180
+ * so nothing outside the recipe is packed, and in the guest nothing is written through one.
181
+ * Of a mode only the execute bit is kept, as git keeps it, so a checkout's umask changes
182
+ * nothing. Entries go in sorted order and nothing records a time or an owner, so the same
183
+ * files pack into the same script: a prebuild is named after it.
76
184
  */
77
185
  const packRecipe = (directory) => Effect.gen(function* () {
78
186
  const fs = yield* FileSystem.FileSystem;
79
187
  const noSetup = new Invalid({ message: `recipe ${directory} has no setup.sh` });
80
188
  if ((yield* fs.stat(`${directory}/setup.sh`).pipe(Effect.mapError((error) => Predicate.isTagged(error.reason, "NotFound") ? noSetup : error))).type !== "File") return yield* noSetup;
81
- const tar = yield* (yield* ChildProcessSpawner.ChildProcessSpawner).spawn(ChildProcess.make("tar", [
82
- "--no-xattrs",
83
- "-c",
84
- "-f",
85
- "-",
86
- "-C",
87
- directory,
88
- "."
89
- ], {
90
- env: { COPYFILE_DISABLE: "1" },
91
- extendEnv: true,
92
- stdin: "ignore"
93
- }));
94
- const [archive, errors, code] = yield* Effect.all([
95
- Stream.mkUint8Array(tar.stdout),
96
- Stream.mkString(Stream.decodeText(tar.stderr)),
97
- tar.exitCode
98
- ], { concurrency: "unbounded" });
99
- if (code !== 0) return yield* new Invalid({ message: `tar couldn't pack recipe ${directory} (exit ${code}): ${errors.trim()}` });
100
- return selfExtracting(Buffer.from(archive.buffer, archive.byteOffset, archive.byteLength).toString("base64"));
101
- }).pipe(Effect.scoped, Effect.catchTag("PlatformError", (error) => Effect.fail(new Invalid({ message: `couldn't pack recipe ${directory}: ${describeFileError(error)}` }))));
189
+ const steps = [];
190
+ /** Packs the entries of the recipe's directory `under`, `""` being the recipe itself. */
191
+ const walk = (under) => Effect.gen(function* () {
192
+ const names = yield* fs.readDirectory(under === "" ? directory : `${directory}/${under}`);
193
+ for (const name of names.toSorted()) {
194
+ const entry = under === "" ? name : `${under}/${name}`;
195
+ const path = `${directory}/${entry}`;
196
+ const link = yield* Effect.option(fs.readLink(path));
197
+ if (Option.isSome(link)) {
198
+ steps.push(`ln -s -- ${quote(link.value)} ${argument(entry)}`);
199
+ continue;
200
+ }
201
+ const info = yield* fs.stat(path);
202
+ if (info.type === "Directory") {
203
+ steps.push(`mkdir ${argument(entry)}`);
204
+ yield* walk(entry);
205
+ } else if (info.type === "File") {
206
+ steps.push(`base64 -d >${argument(entry)} <<'${terminator}'`, wrap(Buffer.from(yield* fs.readFile(path)).toString("base64")), terminator);
207
+ if ((info.mode & executable) !== 0) steps.push(`chmod +x ${argument(entry)}`);
208
+ } else return yield* new Invalid({ message: `recipe ${directory} holds ${entry}, a ${info.type}: a recipe packs only files, directories and symlinks` });
209
+ }
210
+ });
211
+ yield* walk("");
212
+ return selfExtracting(steps);
213
+ }).pipe(Effect.catchTag("PlatformError", (error) => Effect.fail(new Invalid({ message: `couldn't pack recipe ${directory}: ${describeFileError(error)}` }))));
102
214
  /** Reads a setup: a script file's text, or a recipe directory packed into one script. */
103
215
  const readSetup = (path) => Effect.gen(function* () {
104
216
  const fs = yield* FileSystem.FileSystem;
@@ -106,4 +218,4 @@ const readSetup = (path) => Effect.gen(function* () {
106
218
  return yield* fs.readFileString(path);
107
219
  }).pipe(Effect.catchTag("PlatformError", (error) => Effect.fail(new Invalid({ message: `couldn't read setup ${path}: ${describeFileError(error)}` }))));
108
220
  //#endregion
109
- export { ActionName, ActionRecord, Capacity, Checkpoint, client_exports as Client, Conflict, ConflictKind, CreateRequest, ErrorTag, Host, HostErrorTag, HostId, Http, Id, Internal, Invalid, Limit, Machine, MachineSpec, Name, NotFound, Precondition, Profile, Runtime, Setup, SshEndpoint, SshLogin, Unavailable, describeFileError, describeIssues, formatId, formatMib, hostErrors, loadProfile, packRecipe, parseId, parseName, readSetup, readTimeout, release, version, versionHeader };
221
+ export { ActionName, ActionRecord, Capacity, Checkpoint, client_exports as Client, Conflict, ConflictKind, CreateRequest, ErrorTag, Host, HostErrorTag, HostId, Http, Id, Internal, Invalid, Limit, Machine, MachineSpec, Name, NotFound, prebuild_exports as Prebuild, Precondition, Profile, Runtime, Setup, SshEndpoint, SshLogin, Unavailable, describeFileError, describeIssues, formatId, formatMib, hostErrors, loadProfile, packRecipe, parseId, parseName, readSetup, readTimeout, release, version, versionHeader };
package/dist/node.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { O as Invalid, n as HostEntry, r as Options, t as Client } from "./client-SX6jTU1R.mjs";
1
+ import { A as Invalid, a as Options, r as HostEntry, t as Client } from "./client-Cn1bj4It.mjs";
2
2
  import { Layer } from "effect";
3
3
  //#region src/node.d.ts
4
4
  /** Node's `http` module sets no timeout of its own: a mutation can run for hours. */
package/dist/node.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { r as make, t as Client } from "./client-cFk3r2_1.mjs";
1
+ import { r as make, t as Client } from "./client-CtI-rSJQ.mjs";
2
2
  import { Layer } from "effect";
3
3
  import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient";
4
4
  //#region src/node.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjermundgaraba/clankerbox-sdk",
3
- "version": "0.15.2",
3
+ "version": "0.15.3",
4
4
  "description": "clankerbox's API contract and client library, for Effect",
5
5
  "license": "MIT",
6
6
  "repository": {