endia 0.0.0-stage → 0.0.2

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 (101) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +34 -2
  3. package/dist/archives/archive-file.d.ts +36 -0
  4. package/dist/archives/archive-file.js +71 -0
  5. package/dist/archives/build-context.d.ts +63 -0
  6. package/dist/archives/build-context.js +132 -0
  7. package/dist/archives/docker-ignore.d.ts +57 -0
  8. package/dist/archives/docker-ignore.js +250 -0
  9. package/dist/archives/fleet-archive.d.ts +33 -0
  10. package/dist/archives/fleet-archive.js +134 -0
  11. package/dist/compose-model.d.ts +32 -0
  12. package/dist/compose-model.js +39 -0
  13. package/dist/contract/archives.d.ts +14 -0
  14. package/dist/contract/archives.js +1 -0
  15. package/dist/contract/bases.d.ts +48 -0
  16. package/dist/contract/bases.js +1 -0
  17. package/dist/contract/clients.d.ts +29 -0
  18. package/dist/contract/clients.js +1 -0
  19. package/dist/contract/common.d.ts +30 -0
  20. package/dist/contract/common.js +1 -0
  21. package/dist/contract/compose.d.ts +27 -0
  22. package/dist/contract/compose.js +1 -0
  23. package/dist/contract/desktop.d.ts +269 -0
  24. package/dist/contract/desktop.js +1 -0
  25. package/dist/contract/execs.d.ts +61 -0
  26. package/dist/contract/execs.js +1 -0
  27. package/dist/contract/fleets.d.ts +136 -0
  28. package/dist/contract/fleets.js +1 -0
  29. package/dist/contract/images.d.ts +130 -0
  30. package/dist/contract/images.js +1 -0
  31. package/dist/contract/machines.d.ts +199 -0
  32. package/dist/contract/machines.js +1 -0
  33. package/dist/contract/operations.d.ts +28 -0
  34. package/dist/contract/operations.js +1 -0
  35. package/dist/contract/processes.d.ts +75 -0
  36. package/dist/contract/processes.js +1 -0
  37. package/dist/contract/purposes.d.ts +62 -0
  38. package/dist/contract/purposes.js +1 -0
  39. package/dist/contract/space.d.ts +114 -0
  40. package/dist/contract/space.js +1 -0
  41. package/dist/contract/stats.d.ts +58 -0
  42. package/dist/contract/stats.js +1 -0
  43. package/dist/contract/status.d.ts +151 -0
  44. package/dist/contract/status.js +1 -0
  45. package/dist/contract/volumes.d.ts +22 -0
  46. package/dist/contract/volumes.js +1 -0
  47. package/dist/eastindia-client.d.ts +118 -0
  48. package/dist/eastindia-client.js +142 -0
  49. package/dist/environment.d.ts +52 -0
  50. package/dist/environment.js +133 -0
  51. package/dist/errors.d.ts +81 -0
  52. package/dist/errors.js +108 -0
  53. package/dist/exec/exec-frame.d.ts +38 -0
  54. package/dist/exec/exec-frame.js +50 -0
  55. package/dist/exec/exec-session.d.ts +64 -0
  56. package/dist/exec/exec-session.js +117 -0
  57. package/dist/groups/archives.d.ts +78 -0
  58. package/dist/groups/archives.js +117 -0
  59. package/dist/groups/bases.d.ts +28 -0
  60. package/dist/groups/bases.js +39 -0
  61. package/dist/groups/certificates.d.ts +32 -0
  62. package/dist/groups/certificates.js +43 -0
  63. package/dist/groups/desktop.d.ts +143 -0
  64. package/dist/groups/desktop.js +182 -0
  65. package/dist/groups/execs.d.ts +110 -0
  66. package/dist/groups/execs.js +119 -0
  67. package/dist/groups/fleets.d.ts +206 -0
  68. package/dist/groups/fleets.js +255 -0
  69. package/dist/groups/images.d.ts +130 -0
  70. package/dist/groups/images.js +109 -0
  71. package/dist/groups/machines.d.ts +124 -0
  72. package/dist/groups/machines.js +127 -0
  73. package/dist/groups/operations.d.ts +127 -0
  74. package/dist/groups/operations.js +174 -0
  75. package/dist/groups/processes.d.ts +77 -0
  76. package/dist/groups/processes.js +75 -0
  77. package/dist/groups/stats.d.ts +29 -0
  78. package/dist/groups/stats.js +33 -0
  79. package/dist/groups/system.d.ts +33 -0
  80. package/dist/groups/system.js +40 -0
  81. package/dist/groups/volumes.d.ts +36 -0
  82. package/dist/groups/volumes.js +40 -0
  83. package/dist/index.d.ts +37 -0
  84. package/dist/index.js +20 -0
  85. package/dist/purposes.d.ts +17 -0
  86. package/dist/purposes.js +21 -0
  87. package/dist/routes.d.ts +71 -0
  88. package/dist/routes.js +73 -0
  89. package/dist/transport/api-connection.d.ts +185 -0
  90. package/dist/transport/api-connection.js +396 -0
  91. package/dist/transport/connection-failures.d.ts +14 -0
  92. package/dist/transport/connection-failures.js +57 -0
  93. package/dist/transport/json-lines.d.ts +10 -0
  94. package/dist/transport/json-lines.js +23 -0
  95. package/dist/transport/query-text.d.ts +49 -0
  96. package/dist/transport/query-text.js +62 -0
  97. package/dist/transport/service-address.d.ts +30 -0
  98. package/dist/transport/service-address.js +37 -0
  99. package/dist/transport/tls-files.d.ts +9 -0
  100. package/dist/transport/tls-files.js +1 -0
  101. package/package.json +44 -5
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Docker's name for an ignore file. A recipe may have its own ignore file beside it, named as the recipe's file name followed by
3
+ * this name, such as `recipes/dotnet.Dockerfile.dockerignore`.
4
+ */
5
+ export const IgnoreFileName = ".dockerignore";
6
+ /** EastIndia's name for an ignore file, which a build reads in preference to a `.dockerignore` in the same place. */
7
+ export const EndiaIgnoreFileName = ".endiaignore";
8
+ /** The first character of a comment line. */
9
+ const CommentMarker = "#";
10
+ /** The first character of a pattern that re-includes what earlier patterns excluded. */
11
+ const ReincludeMarker = "!";
12
+ /** A leading byte-order mark, which the file may start with and which is not part of its first line. */
13
+ const ByteOrderMark = "";
14
+ /**
15
+ * A build context's ignore file: which of the context's files a build leaves out of the archive it sends, so that no `COPY` can copy
16
+ * them and they do not change the build key. It matches paths by the same rules as the `endia` CLI, which follows Docker's
17
+ * `.dockerignore` matching. A folder sent by this client therefore gives the same archive as the same folder sent by the CLI.
18
+ *
19
+ * Patterns are cleaned and anchored at the context root, and a leading `/` is dropped. `*` and `?` stop at `/`, `**` spans folders,
20
+ * and `[...]` is a character class. A later line overrides an earlier one, and `!` re-includes. A path is ignored when the pattern
21
+ * that decides it matches the path itself or any of its parent folders.
22
+ */
23
+ export class DockerIgnore {
24
+ patterns;
25
+ constructor(patterns) {
26
+ this.patterns = patterns;
27
+ }
28
+ /** An ignore file that excludes nothing, for a context without one. */
29
+ static empty = new DockerIgnore([]);
30
+ /**
31
+ * Reads the text of an ignore file. Lines end in LF or CRLF. Blank lines and lines whose first non-blank character is `#` are
32
+ * skipped.
33
+ *
34
+ * @param text - The file's content.
35
+ * @param file - The file's path, which error messages name.
36
+ * @returns The ignore file.
37
+ * @throws Error naming the file, the line and the column of every malformed pattern.
38
+ */
39
+ static parse(text, file = IgnoreFileName) {
40
+ const patterns = [];
41
+ const problems = [];
42
+ const lines = (text.startsWith(ByteOrderMark) ? text.slice(1) : text).split("\n").map((line) => line.replace(/\r$/u, ""));
43
+ lines.forEach((raw, index) => {
44
+ const trimmed = raw.trim();
45
+ if (trimmed.length === 0 || trimmed.startsWith(CommentMarker)) {
46
+ return;
47
+ }
48
+ const at = `${file}:${String(index + 1)}:${String(raw.length - raw.trimStart().length + 1)}`;
49
+ const reincludes = trimmed.startsWith(ReincludeMarker);
50
+ const body = reincludes ? trimmed.slice(1).trim() : trimmed;
51
+ if (body.length === 0) {
52
+ problems.push(`${at}: A '${ReincludeMarker}' needs a pattern after it.`);
53
+ return;
54
+ }
55
+ // Patterns are anchored at the context root, so a leading '/' says nothing more.
56
+ let cleaned = cleanPath(body);
57
+ if (cleaned.length > 1 && cleaned.startsWith("/")) {
58
+ cleaned = cleaned.slice(1);
59
+ }
60
+ const compiled = compilePattern(cleaned);
61
+ if (typeof compiled === "string") {
62
+ problems.push(`${at}: The pattern '${trimmed}' is not valid: ${compiled}.`);
63
+ }
64
+ else {
65
+ patterns.push({ expression: compiled, reincludes });
66
+ }
67
+ });
68
+ if (problems.length > 0) {
69
+ throw new Error(problems.join("\n"));
70
+ }
71
+ return new DockerIgnore(patterns);
72
+ }
73
+ /**
74
+ * Whether any line re-includes with `!`. Only then can a path under a left-out folder be sent, so only then must a walk of the
75
+ * context look inside a left-out folder.
76
+ */
77
+ get reincludes() {
78
+ return this.patterns.some((pattern) => pattern.reincludes);
79
+ }
80
+ /**
81
+ * Decides whether a context path is left out. The patterns apply in file order, and the last pattern that matches decides. The
82
+ * context root itself is never left out.
83
+ *
84
+ * @param path - A path relative to the context root, with `/` separators.
85
+ * @returns True when the path, or one of its parent folders, is left out.
86
+ * @throws Error when the path leads outside the context.
87
+ */
88
+ isIgnored(path) {
89
+ const cleaned = cleanPath(path).replace(/^\/+/u, "");
90
+ if (cleaned === ".." || cleaned.startsWith("../")) {
91
+ throw new Error(`The path '${path}' points outside the build context.`);
92
+ }
93
+ if (cleaned.length === 0 || cleaned === ".") {
94
+ return false;
95
+ }
96
+ const parents = [];
97
+ for (let slash = cleaned.indexOf("/"); slash >= 0; slash = cleaned.indexOf("/", slash + 1)) {
98
+ parents.push(cleaned.slice(0, slash));
99
+ }
100
+ let ignored = false;
101
+ for (const pattern of this.patterns) {
102
+ // A re-including pattern can only undo a match, and an excluding one can only add one, so a pattern that could change nothing is skipped.
103
+ if (pattern.reincludes !== ignored) {
104
+ continue;
105
+ }
106
+ if (pattern.expression.test(cleaned) || parents.some((parent) => pattern.expression.test(parent))) {
107
+ ignored = !pattern.reincludes;
108
+ }
109
+ }
110
+ return ignored;
111
+ }
112
+ }
113
+ /**
114
+ * Cleans a path with `/` separators by the rules of Go's `path.Clean`, which Docker applies to ignore patterns.
115
+ *
116
+ * - Empty and `.` segments are dropped.
117
+ * - A `..` removes the segment before it; at the start of a relative path it stays.
118
+ * - An empty result is `.`.
119
+ *
120
+ * @param path - The path.
121
+ * @returns The cleaned path.
122
+ */
123
+ export function cleanPath(path) {
124
+ if (path.length === 0) {
125
+ return ".";
126
+ }
127
+ const rooted = path.startsWith("/");
128
+ const segments = [];
129
+ for (const segment of path.split("/")) {
130
+ if (segment.length === 0 || segment === ".") {
131
+ continue;
132
+ }
133
+ if (segment === "..") {
134
+ if (segments.length > 0 && segments[segments.length - 1] !== "..") {
135
+ segments.pop();
136
+ }
137
+ else if (!rooted) {
138
+ segments.push("..");
139
+ }
140
+ continue;
141
+ }
142
+ segments.push(segment);
143
+ }
144
+ const joined = segments.join("/");
145
+ return rooted ? `/${joined}` : joined.length === 0 ? "." : joined;
146
+ }
147
+ /**
148
+ * Translates a cleaned pattern into a regular expression over a whole context path. `*` becomes any characters other than `/`, `?`
149
+ * becomes one such character, `**` spans folders, `[...]` is a character class, and a backslash escapes the next character.
150
+ *
151
+ * @returns The expression, or the reason the pattern is not valid.
152
+ */
153
+ function compilePattern(pattern) {
154
+ let text = "^";
155
+ for (let index = 0; index < pattern.length;) {
156
+ const character = pattern.charAt(index);
157
+ if (character === "*") {
158
+ const star = translateStar(pattern, index);
159
+ text += star.text;
160
+ index = star.next;
161
+ }
162
+ else if (character === "?") {
163
+ text += "[^/]";
164
+ index++;
165
+ }
166
+ else if (character === "[") {
167
+ const characterClass = translateClass(pattern, index);
168
+ if (characterClass === undefined) {
169
+ return "a '[' has no closing ']'";
170
+ }
171
+ text += characterClass.text;
172
+ index = characterClass.next;
173
+ }
174
+ else if (character === "\\") {
175
+ if (index + 1 >= pattern.length) {
176
+ return "a trailing '\\' escapes nothing";
177
+ }
178
+ text += escapeCharacter(pattern.charAt(index + 1));
179
+ index += 2;
180
+ }
181
+ else {
182
+ text += escapeCharacter(character);
183
+ index++;
184
+ }
185
+ }
186
+ return new RegExp(`${text}$`, "su");
187
+ }
188
+ /**
189
+ * Translates the `*` or `**` at an index. A single `*` matches any characters other than `/`. A `**` consumes a `/` that follows it;
190
+ * at the end of the pattern it matches anything, and elsewhere any number of whole folders, including none, so `logs/**` does not
191
+ * match `logs` itself.
192
+ */
193
+ function translateStar(pattern, index) {
194
+ if (pattern.charAt(index + 1) !== "*") {
195
+ return { text: "[^/]*", next: index + 1 };
196
+ }
197
+ let next = index + 2;
198
+ if (pattern.charAt(next) === "/") {
199
+ next++;
200
+ }
201
+ return { text: next >= pattern.length ? ".*" : "(?:.*/)?", next };
202
+ }
203
+ /**
204
+ * Translates the `[...]` class at an index. In a class, `^` first negates, a backslash escapes, `a-z` is a range, and a `]` first in
205
+ * the class is a literal.
206
+ *
207
+ * @returns The translation and the index after the class, or undefined when the class has no closing `]`.
208
+ */
209
+ function translateClass(pattern, index) {
210
+ let position = index + 1;
211
+ let text = "[";
212
+ if (pattern.charAt(position) === "^") {
213
+ text += "^";
214
+ position++;
215
+ }
216
+ let members = 0;
217
+ while (position < pattern.length && (pattern.charAt(position) !== "]" || members === 0)) {
218
+ let character = pattern.charAt(position);
219
+ if (character === "\\") {
220
+ position++;
221
+ if (position >= pattern.length) {
222
+ break;
223
+ }
224
+ character = pattern.charAt(position);
225
+ text += /[a-zA-Z0-9]/u.test(character) ? character : escapeClassCharacter(character);
226
+ }
227
+ else if (character === "-" && members > 0 && position + 1 < pattern.length && pattern.charAt(position + 1) !== "]") {
228
+ text += "-";
229
+ }
230
+ else {
231
+ text += escapeClassCharacter(character);
232
+ }
233
+ members++;
234
+ position++;
235
+ }
236
+ return position >= pattern.length ? undefined : { text: `${text}]`, next: position + 1 };
237
+ }
238
+ /**
239
+ * The characters a regular expression gives a meaning of its own outside a class. A regular expression in Unicode mode refuses an
240
+ * escape of any other character, so only these are escaped.
241
+ */
242
+ const SyntaxCharacters = /[\\^$.*+?()[\]{}|/]/u;
243
+ /** The characters with a meaning of their own inside a class: the syntax characters and `-`, which writes a range. */
244
+ const ClassSyntaxCharacters = /[\\^$.*+?()[\]{}|/-]/u;
245
+ function escapeCharacter(character) {
246
+ return SyntaxCharacters.test(character) ? `\\${character}` : character;
247
+ }
248
+ function escapeClassCharacter(character) {
249
+ return ClassSyntaxCharacters.test(character) ? `\\${character}` : character;
250
+ }
@@ -0,0 +1,33 @@
1
+ import { ArchiveFile } from "./archive-file.js";
2
+ import type { ComposeModel } from "../contract/compose.js";
3
+ import type { PurposeBody } from "../purposes.js";
4
+ /**
5
+ * Writes the body of `compose up`, as the `endia` CLI writes it: one tar archive holding what the service needs from the caller's
6
+ * side, because a caller in a sandbox shares no folder with the host.
7
+ *
8
+ * - `compose.json` is the resolved model.
9
+ * - `contexts/<service>/` holds the build context of each selected service that has `build`, with the files a build would send.
10
+ * - `binds/<service>/<index>` holds the source of each bind mount of every service, by the index of its entry in the service's
11
+ * `volumes`, counted from 0: a folder with everything under it, or a file.
12
+ * - `purpose.json` is the purpose's title and detail, present only when the caller sets the fleet's purpose as it comes up; the
13
+ * service stores it before any other work.
14
+ *
15
+ * @param model - The resolved model.
16
+ * @param modelText - The model's JSON text, sent as `compose.json` exactly as the caller gave it.
17
+ * @param services - The services named for `compose up`; none selects every service. The selection adds the services they depend on.
18
+ * @param purpose - The fleet's purpose to set as it comes up, as `purposeBody` gives it; undefined leaves the fleet's purpose as it is.
19
+ * @returns The archive; the caller deletes it.
20
+ * @throws Error when a built service's context is not a folder or its recipe lies outside it, or when a bind mount's source does not
21
+ * exist. Nothing is sent then.
22
+ */
23
+ export declare function packFleet(model: ComposeModel, modelText: string, services: readonly string[], purpose?: PurposeBody): Promise<ArchiveFile>;
24
+ /**
25
+ * Gives the selected services: those named with every service they depend on, directly or through another, or every service when
26
+ * none is named.
27
+ *
28
+ * @param model - The resolved model.
29
+ * @param named - The services named.
30
+ * @returns The selected services' names.
31
+ * @throws Error when a named service is not in the model.
32
+ */
33
+ export declare function selectServices(model: ComposeModel, named: readonly string[]): ReadonlySet<string>;
@@ -0,0 +1,134 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { readdir, realpath, stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { ArchiveFile } from "./archive-file.js";
5
+ import { contextEntries, defaultRecipe, ignoreFileFor, pathInside } from "./build-context.js";
6
+ /** The name of the resolved model in the archive. */
7
+ const ModelEntry = "compose.json";
8
+ /** The name of the purpose the caller sets as the fleet comes up, in the archive. */
9
+ const PurposeEntry = "purpose.json";
10
+ /** The folder of the archive that holds each built service's context, as `contexts/<service>/`. */
11
+ const ContextsFolder = "contexts";
12
+ /** The folder of the archive that holds each bind mount's source, as `binds/<service>/<index of the volume entry>`. */
13
+ const BindsFolder = "binds";
14
+ /** The type of a volume entry whose source is a file or folder on the caller's side. */
15
+ const BindType = "bind";
16
+ /**
17
+ * Writes the body of `compose up`, as the `endia` CLI writes it: one tar archive holding what the service needs from the caller's
18
+ * side, because a caller in a sandbox shares no folder with the host.
19
+ *
20
+ * - `compose.json` is the resolved model.
21
+ * - `contexts/<service>/` holds the build context of each selected service that has `build`, with the files a build would send.
22
+ * - `binds/<service>/<index>` holds the source of each bind mount of every service, by the index of its entry in the service's
23
+ * `volumes`, counted from 0: a folder with everything under it, or a file.
24
+ * - `purpose.json` is the purpose's title and detail, present only when the caller sets the fleet's purpose as it comes up; the
25
+ * service stores it before any other work.
26
+ *
27
+ * @param model - The resolved model.
28
+ * @param modelText - The model's JSON text, sent as `compose.json` exactly as the caller gave it.
29
+ * @param services - The services named for `compose up`; none selects every service. The selection adds the services they depend on.
30
+ * @param purpose - The fleet's purpose to set as it comes up, as `purposeBody` gives it; undefined leaves the fleet's purpose as it is.
31
+ * @returns The archive; the caller deletes it.
32
+ * @throws Error when a built service's context is not a folder or its recipe lies outside it, or when a bind mount's source does not
33
+ * exist. Nothing is sent then.
34
+ */
35
+ export async function packFleet(model, modelText, services, purpose) {
36
+ const selected = selectServices(model, services);
37
+ const entries = [];
38
+ for (const [name, service] of Object.entries(model.services)) {
39
+ if (service.build && selected.has(name)) {
40
+ entries.push(...await builtContextEntries(name, service.build));
41
+ }
42
+ for (const [index, volume] of (service.volumes ?? []).entries()) {
43
+ if (volume.type === BindType && volume.source !== undefined) {
44
+ entries.push(...await bindEntries(name, index, volume.source));
45
+ }
46
+ }
47
+ }
48
+ const texts = [{ name: ModelEntry, text: modelText }];
49
+ if (purpose !== undefined) {
50
+ texts.push({ name: PurposeEntry, text: JSON.stringify(purpose) });
51
+ }
52
+ return ArchiveFile.write(entries, texts, true);
53
+ }
54
+ /**
55
+ * Gives the selected services: those named with every service they depend on, directly or through another, or every service when
56
+ * none is named.
57
+ *
58
+ * @param model - The resolved model.
59
+ * @param named - The services named.
60
+ * @returns The selected services' names.
61
+ * @throws Error when a named service is not in the model.
62
+ */
63
+ export function selectServices(model, named) {
64
+ if (named.length === 0) {
65
+ return new Set(Object.keys(model.services));
66
+ }
67
+ const selected = new Set();
68
+ const pending = [...named];
69
+ for (let name = pending.pop(); name !== undefined; name = pending.pop()) {
70
+ const service = model.services[name];
71
+ if (service === undefined) {
72
+ throw new Error(`The Compose model has no service "${name}".`);
73
+ }
74
+ if (!selected.has(name)) {
75
+ selected.add(name);
76
+ pending.push(...dependenciesOf(service));
77
+ }
78
+ }
79
+ return selected;
80
+ }
81
+ function dependenciesOf(service) {
82
+ const dependsOn = service.depends_on ?? [];
83
+ return Array.isArray(dependsOn) ? [...dependsOn] : Object.keys(dependsOn);
84
+ }
85
+ // A context's files are named under contexts/<service>/; its folders are not sent, only the root's own entry. The service accepts
86
+ // only files and folders in this archive, so a link in the context is sent as what it points at: a link to a file as that file,
87
+ // and a link to a folder as an empty folder.
88
+ async function builtContextEntries(service, build) {
89
+ const context = build.context;
90
+ if (context === undefined) {
91
+ throw new Error(`Service "${service}" has a build without a context.`);
92
+ }
93
+ if (!existsSync(context) || !statSync(context).isDirectory()) {
94
+ throw new Error(`The build context '${context}' of service "${service}" is not a folder.`);
95
+ }
96
+ const recipe = path.resolve(context, build.dockerfile ?? defaultRecipe(path.resolve(context)));
97
+ const inside = pathInside(context, recipe);
98
+ if (inside === undefined) {
99
+ throw new Error(`The recipe '${recipe}' of service "${service}" must be inside its build context '${context}'.`);
100
+ }
101
+ const root = `${ContextsFolder}/${service}/`;
102
+ const files = (await contextEntries(path.resolve(context), inside, ignoreFileFor(path.resolve(context), recipe))).filter((entry) => !entry.name.endsWith("/"));
103
+ return [{ source: context, name: root }, ...files.map((entry) => ({ source: entry.source, name: root + entry.name }))];
104
+ }
105
+ async function bindEntries(service, index, source) {
106
+ const root = `${BindsFolder}/${service}/${String(index)}`;
107
+ const item = await stat(source).catch(() => undefined);
108
+ if (item === undefined) {
109
+ throw new Error(`The bind mount source '${source}' of service "${service}" does not exist.`);
110
+ }
111
+ if (!item.isDirectory()) {
112
+ return [{ source, name: root }];
113
+ }
114
+ const entries = [{ source, name: `${root}/` }];
115
+ await collectEverything(source, `${root}/`, entries, new Set([await realpath(source)]));
116
+ return entries;
117
+ }
118
+ // Every folder and file under a folder, each folder before what it holds. A link is followed to what it points at, because the
119
+ // service accepts only files and folders in this archive; a link back to a folder already being walked is refused.
120
+ async function collectEverything(folder, prefix, entries, walking) {
121
+ for (const name of (await readdir(folder)).sort()) {
122
+ const source = path.join(folder, name);
123
+ if (!(await stat(source)).isDirectory()) {
124
+ entries.push({ source, name: prefix + name });
125
+ continue;
126
+ }
127
+ const real = await realpath(source);
128
+ if (walking.has(real)) {
129
+ throw new Error(`The folder '${source}' leads back to a folder that holds it.`);
130
+ }
131
+ entries.push({ source, name: `${prefix}${name}/` });
132
+ await collectEverything(source, `${prefix}${name}/`, entries, new Set([...walking, real]));
133
+ }
134
+ }
@@ -0,0 +1,32 @@
1
+ import type { ComposeModel } from "./contract/compose.js";
2
+ /** Which Compose project to resolve, as the options of `endia compose` choose it. */
3
+ export interface ComposeProjectOptions {
4
+ /** The Compose files, in order; when undefined, the CLI finds them as `docker compose` does, from the working folder. */
5
+ readonly files?: readonly string[];
6
+ /** The Compose project's name; the service names the fleet after it. */
7
+ readonly projectName?: string;
8
+ /** The folder against which relative paths are resolved; the first file's folder when undefined. */
9
+ readonly projectDirectory?: string;
10
+ /** Files of variables whose values the CLI fills into the Compose files; `.env` in the project folder when undefined. */
11
+ readonly envFiles?: readonly string[];
12
+ /** The working folder of the CLI. When `files` is undefined, the CLI looks for Compose files in this folder and its parents. */
13
+ readonly cwd?: string;
14
+ /** The `endia` program; `endia` on the `PATH` when undefined. */
15
+ readonly cli?: string;
16
+ }
17
+ /** The resolved model, both as the object the client reads and as the exact JSON text the CLI printed. */
18
+ export interface ResolvedComposeModel {
19
+ readonly model: ComposeModel;
20
+ readonly text: string;
21
+ }
22
+ /**
23
+ * Resolves a Compose project with the `endia` CLI, for `FleetsClient.up` to send. The client does not read Compose files itself.
24
+ * The CLI reads the files, merges them, fills in their variables and checks them by the rules the service applies. It then prints
25
+ * the resolved model, with every path absolute, as `endia compose config --format json`. A caller without the CLI can write the
26
+ * resolved model itself.
27
+ *
28
+ * @param options - The files, the project name and folder, and the variable files.
29
+ * @returns The resolved model.
30
+ * @throws Error with the CLI's own message when the project is not valid or the CLI cannot be run.
31
+ */
32
+ export declare function resolveComposeModel(options?: ComposeProjectOptions): Promise<ResolvedComposeModel>;
@@ -0,0 +1,39 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const run = promisify(execFile);
4
+ /** The most output read from the CLI, far more than any resolved model needs; Node's default of 1 MiB could cut a large model short. */
5
+ const MaxModelBytes = 64 * 1024 * 1024;
6
+ /**
7
+ * Resolves a Compose project with the `endia` CLI, for `FleetsClient.up` to send. The client does not read Compose files itself.
8
+ * The CLI reads the files, merges them, fills in their variables and checks them by the rules the service applies. It then prints
9
+ * the resolved model, with every path absolute, as `endia compose config --format json`. A caller without the CLI can write the
10
+ * resolved model itself.
11
+ *
12
+ * @param options - The files, the project name and folder, and the variable files.
13
+ * @returns The resolved model.
14
+ * @throws Error with the CLI's own message when the project is not valid or the CLI cannot be run.
15
+ */
16
+ export async function resolveComposeModel(options = {}) {
17
+ const args = ["compose"];
18
+ for (const file of options.files ?? []) {
19
+ args.push("--file", file);
20
+ }
21
+ if (options.projectName !== undefined) {
22
+ args.push("--project-name", options.projectName);
23
+ }
24
+ if (options.projectDirectory !== undefined) {
25
+ args.push("--project-directory", options.projectDirectory);
26
+ }
27
+ for (const file of options.envFiles ?? []) {
28
+ args.push("--env-file", file);
29
+ }
30
+ args.push("config", "--format", "json");
31
+ try {
32
+ const { stdout } = await run(options.cli ?? "endia", args, { cwd: options.cwd, maxBuffer: MaxModelBytes, encoding: "utf8" });
33
+ return { model: JSON.parse(stdout), text: stdout };
34
+ }
35
+ catch (error) {
36
+ const stderr = typeof error === "object" && error !== null && "stderr" in error ? String(error.stderr).trim() : "";
37
+ throw new Error(`endia compose config failed${stderr === "" ? "" : `: ${stderr}`}`, { cause: error });
38
+ }
39
+ }
@@ -0,0 +1,14 @@
1
+ import type { Timestamp } from "./common.js";
2
+ /** What a path in a machine is, as the archive routes describe it in their `X-EastIndia-Path-Stat` header, Docker's `X-Docker-Container-Path-Stat`: its name, size, kind, time of last change and, for a link, what it points at. `endia cp` decides where a copy lands from it. */
3
+ export interface PathStat {
4
+ /** The path's last name. */
5
+ readonly name: string;
6
+ /** A file's size in bytes; 0 for a folder or a link. */
7
+ readonly size: number;
8
+ /** What the path is: `file`, `directory` or `link`. */
9
+ readonly mode: string;
10
+ /** When the path last changed. */
11
+ readonly mtime: Timestamp;
12
+ /** What a link points at, as the link holds it; null for a file or a folder. */
13
+ readonly linkTarget: string | null;
14
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,48 @@
1
+ /** The JSON body of `POST /v1/bases/import`, which `endia import` sends. The import makes a Windows disk that is already on the host into an image with no parent. The request names the disk, the reference the image will carry, whether to move or copy the disk into the image folder, and the recipe instructions that set the image's configuration, as `docker import --change` does. */
2
+ export interface BaseImportRequest {
3
+ /** The full path of the disk on the host, for example `M:/eastindia/win11-desktop.vhdx`. */
4
+ readonly path: string;
5
+ /** The name and optional tag that the image will carry, for example `lab/windows-desktop:approved`; without a tag, `latest`. */
6
+ readonly reference: string;
7
+ /** Whether to move the disk into the image folder; when false, the disk is copied there. */
8
+ readonly move?: boolean;
9
+ /** The recipe instructions applied to the image's configuration, in order, each one line such as `ENV LANG=en-GB`. Only `LABEL`, `ENV`, `WORKDIR`, `CMD`, `ENTRYPOINT` and `EXPOSE` are accepted. */
10
+ readonly changes?: readonly string[];
11
+ }
12
+ /** The JSON body of `POST /v1/bases/prepare`, which `endia base prepare` sends: install Windows from an installation disc on the host, with EastIndia's guest programs and SSH, and import the result as a base. The password reaches only the answer file inside the new Windows and the service's memory; the service writes it nowhere else. */
13
+ export interface BasePrepareRequest {
14
+ /** The full path on the host of the Windows installation disc, an ISO file, for example `M:/eastindia/windows11.iso`. */
15
+ readonly iso: string;
16
+ /** The tag that the base will carry, for example `lab/windows-desktop:approved`. */
17
+ readonly tag: string;
18
+ /** The number of the Windows edition on the disc, counted from 1 as Windows Setup counts them. */
19
+ readonly imageIndex?: number;
20
+ /** The standard account that Windows signs in automatically at every start. */
21
+ readonly desktopUser?: string;
22
+ /** The administrator account whose name and password answer an elevation prompt. */
23
+ readonly adminUser?: string;
24
+ /** The password of both accounts. */
25
+ readonly password: string;
26
+ }
27
+ /** The result of a finished import: the digest and reference of the new image, the path of its disk, how the disk came into the image folder, and what the import check found when the import started a virtual machine from the disk. */
28
+ export interface BaseImportResult {
29
+ /** The image's digest, which is the SHA-256 of its disk; `endia import` prints it. */
30
+ readonly digest: string;
31
+ /** The reference that names the image, in the form `name:tag`. */
32
+ readonly reference: string;
33
+ /** The full path of the disk in the image folder. */
34
+ readonly diskPath: string;
35
+ /** How the disk came into the image folder: `inPlace` when it was already there and is used where it is, `copied` when a copy was made and the original left where it was, or `moved` when the original was renamed into the folder, or copied and then deleted. */
36
+ readonly broughtIn: string;
37
+ /** The checks of the import check, in the order they ran. A finished import passed every one. */
38
+ readonly checks: readonly BaseCheckReport[];
39
+ }
40
+ /** The outcome of one check that an import makes on a virtual machine it starts from the imported disk. Together these checks are the import check. */
41
+ export interface BaseCheckReport {
42
+ /** The fixed name of the check: `guest-service`, `fast-startup`, `desktop`, `ssh`, `evaluation` or `shutdown`. */
43
+ readonly name: string;
44
+ /** Whether the check passed. */
45
+ readonly passed: boolean;
46
+ /** What the check saw, as text, such as the banner that the SSH server sent. */
47
+ readonly detail: string;
48
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,29 @@
1
+ import type { Timestamp } from "./common.js";
2
+ /** One issued client certificate as `GET /v1/clients` lists it and `DELETE /v1/clients/{name}` answers it: the record in `clients.json`, without any key. */
3
+ export interface ClientCertificateSummary {
4
+ /** The certificate's name, its common name. */
5
+ readonly name: string;
6
+ /** `valid`, `expired` or `revoked`. */
7
+ readonly state: string;
8
+ /** The certificate's serial number, in hexadecimal. */
9
+ readonly serialNumber: string;
10
+ /** When the certificate was issued. */
11
+ readonly issued: Timestamp;
12
+ /** When the certificate stops being valid. */
13
+ readonly expires: Timestamp;
14
+ /** When the certificate was revoked, or null. */
15
+ readonly revoked: Timestamp | null;
16
+ }
17
+ /** The answer of `POST /v1/clients`: the three files a caller over HTTPS holds, as PEM text. The service keeps the certificate's record in `clients.json` and does not keep the private key. */
18
+ export interface IssuedClientCertificate {
19
+ /** The certificate's name, its common name. */
20
+ readonly name: string;
21
+ /** When the certificate stops being valid. */
22
+ readonly expires: Timestamp;
23
+ /** `ca.pem`: the authority that signed the service's server certificate. */
24
+ readonly authority: string;
25
+ /** `cert.pem`: the client certificate. */
26
+ readonly certificate: string;
27
+ /** `key.pem`: the client certificate's private key, unencrypted PKCS#8. */
28
+ readonly key: string;
29
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,30 @@
1
+ /**
2
+ * A time as the API writes it: RFC 3339 in UTC with seven fractional digits, such as `2026-10-05T14:03:22.1234567Z`.
3
+ *
4
+ * Times stay strings because a JavaScript `Date` keeps only milliseconds, and the API identifies a process by its exact start time:
5
+ * a `started` value read from one answer is sent back unchanged in later requests.
6
+ */
7
+ export type Timestamp = string;
8
+ /** The JSON form of one refusal or one failed operation. */
9
+ export interface ApiError {
10
+ /** One of the API's error codes, such as `NOT_FOUND`, `TIMEOUT` or `BUILD_FAILED`. */
11
+ readonly code: string;
12
+ /** What was expected and what was found, in one or two sentences. */
13
+ readonly message: string;
14
+ /** The field at fault: a request member, a Compose path or a recipe position; null when no one field is at fault. */
15
+ readonly fieldPath?: string | null;
16
+ /** The code's own data, such as `candidates` for an ambiguous prefix; null when the code carries none. */
17
+ readonly details?: Readonly<Record<string, unknown>> | null;
18
+ }
19
+ /** A rectangle on the screen or in a window, in pixels. */
20
+ export interface WindowBounds {
21
+ readonly x: number;
22
+ readonly y: number;
23
+ readonly width: number;
24
+ readonly height: number;
25
+ }
26
+ /**
27
+ * The filters of a list route, as `endia ps -f` and the other list commands take them: each key with every value given for it,
28
+ * such as `{ label: ["team=qa"], status: ["running"] }`.
29
+ */
30
+ export type Filters = Readonly<Record<string, readonly string[]>>;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,27 @@
1
+ /**
2
+ * A resolved Compose model, as `endia compose config --format json` prints it: long syntax, every path absolute, and the project
3
+ * name set. The service checks the whole model; this client reads only the parts that name files on the caller's side.
4
+ */
5
+ export interface ComposeModel {
6
+ /** The project name, which is the fleet's name. */
7
+ readonly name: string;
8
+ /** The services by name. */
9
+ readonly services: Readonly<Record<string, ComposeService>>;
10
+ readonly [member: string]: unknown;
11
+ }
12
+ /** The parts of one service of a resolved Compose model that name files on the caller's side. */
13
+ export interface ComposeService {
14
+ readonly build?: {
15
+ readonly context?: string;
16
+ readonly dockerfile?: string;
17
+ readonly [member: string]: unknown;
18
+ } | null;
19
+ readonly volumes?: readonly {
20
+ readonly type?: string;
21
+ readonly source?: string;
22
+ readonly [member: string]: unknown;
23
+ }[] | null;
24
+ /** The services this one depends on: a map by service name in the resolved model, or a list of names. */
25
+ readonly depends_on?: Readonly<Record<string, unknown>> | readonly string[] | null;
26
+ readonly [member: string]: unknown;
27
+ }
@@ -0,0 +1 @@
1
+ export {};