promigence 0.1.0-beta.12

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 (97) hide show
  1. package/NOTICE +14 -0
  2. package/README.md +88 -0
  3. package/dist/account.js +850 -0
  4. package/dist/agent.js +374 -0
  5. package/dist/argv.js +159 -0
  6. package/dist/bin.js +332 -0
  7. package/dist/bundle.js +133 -0
  8. package/dist/capacity.js +69 -0
  9. package/dist/commands.d.ts +110 -0
  10. package/dist/commands.js +2675 -0
  11. package/dist/connectreq.js +125 -0
  12. package/dist/credfile.js +188 -0
  13. package/dist/credits.js +197 -0
  14. package/dist/devcontainer.d.ts +99 -0
  15. package/dist/devcontainer.js +744 -0
  16. package/dist/device.js +129 -0
  17. package/dist/dirsnap.js +378 -0
  18. package/dist/errfix.js +262 -0
  19. package/dist/generated.d.ts +35 -0
  20. package/dist/generated.js +307 -0
  21. package/dist/github.js +307 -0
  22. package/dist/gitlab.js +210 -0
  23. package/dist/guide.d.ts +19 -0
  24. package/dist/guide.js +366 -0
  25. package/dist/handlers.js +511 -0
  26. package/dist/index.d.ts +14 -0
  27. package/dist/index.js +7 -0
  28. package/dist/init.d.ts +40 -0
  29. package/dist/init.js +352 -0
  30. package/dist/install.d.ts +23 -0
  31. package/dist/install.js +127 -0
  32. package/dist/io.d.ts +37 -0
  33. package/dist/io.js +318 -0
  34. package/dist/live.js +469 -0
  35. package/dist/login.js +1063 -0
  36. package/dist/mcp.js +889 -0
  37. package/dist/money.js +5 -0
  38. package/dist/orgs.js +475 -0
  39. package/dist/pricing.d.ts +52 -0
  40. package/dist/pricing.js +181 -0
  41. package/dist/report.js +132 -0
  42. package/dist/routes.js +135 -0
  43. package/dist/runners-github.js +257 -0
  44. package/dist/runners-gitlab.js +125 -0
  45. package/dist/runners-spec.js +137 -0
  46. package/dist/runners.js +1458 -0
  47. package/dist/sdk/blake3.d.ts +10 -0
  48. package/dist/sdk/blake3.js +111 -0
  49. package/dist/sdk/bundle.d.ts +96 -0
  50. package/dist/sdk/bundle.js +227 -0
  51. package/dist/sdk/ca-guard.js +166 -0
  52. package/dist/sdk/caller.d.ts +73 -0
  53. package/dist/sdk/caller.js +47 -0
  54. package/dist/sdk/client.d.ts +3015 -0
  55. package/dist/sdk/client.js +2583 -0
  56. package/dist/sdk/command-errors.d.ts +41 -0
  57. package/dist/sdk/command-errors.js +36 -0
  58. package/dist/sdk/context.d.ts +119 -0
  59. package/dist/sdk/context.js +588 -0
  60. package/dist/sdk/core.d.ts +285 -0
  61. package/dist/sdk/core.js +266 -0
  62. package/dist/sdk/credstore.d.ts +90 -0
  63. package/dist/sdk/credstore.js +206 -0
  64. package/dist/sdk/dispatcher.js +86 -0
  65. package/dist/sdk/github.d.ts +158 -0
  66. package/dist/sdk/github.js +78 -0
  67. package/dist/sdk/gitlab.d.ts +104 -0
  68. package/dist/sdk/gitlab.js +20 -0
  69. package/dist/sdk/handles.d.ts +82 -0
  70. package/dist/sdk/handles.js +135 -0
  71. package/dist/sdk/hint.js +7 -0
  72. package/dist/sdk/http2.js +1921 -0
  73. package/dist/sdk/index.d.ts +34 -0
  74. package/dist/sdk/index.js +15 -0
  75. package/dist/sdk/keyed.gen.js +7 -0
  76. package/dist/sdk/models.gen.d.ts +6162 -0
  77. package/dist/sdk/models.gen.js +1 -0
  78. package/dist/sdk/proxy.js +249 -0
  79. package/dist/sdk/pty.d.ts +171 -0
  80. package/dist/sdk/pty.js +496 -0
  81. package/dist/sdk/sandbox.d.ts +537 -0
  82. package/dist/sdk/sandbox.js +725 -0
  83. package/dist/sdk/ssh.d.ts +25 -0
  84. package/dist/sdk/ssh.js +114 -0
  85. package/dist/sdk/trace.js +80 -0
  86. package/dist/sdk/transport-worker.js +466 -0
  87. package/dist/sdk/warm.js +40 -0
  88. package/dist/ssh.js +267 -0
  89. package/dist/surface.d.ts +95 -0
  90. package/dist/surface.js +1452 -0
  91. package/dist/templates.js +53 -0
  92. package/dist/term.js +50 -0
  93. package/dist/timing.js +71 -0
  94. package/dist/untar.js +324 -0
  95. package/dist/uploadguard.js +75 -0
  96. package/llms.txt +209 -0
  97. package/package.json +52 -0
@@ -0,0 +1,744 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
3
+ import { basename, isAbsolute, join, relative, resolve } from "node:path";
4
+ import { TIERS } from "./generated.js";
5
+ import { sh } from "./term.js";
6
+ export const DEVCONTAINER_SPEC = "containers.dev json_reference + features, read 2026-09-18";
7
+ export const DEVCONTAINER_NAMES = [".devcontainer/devcontainer.json", ".devcontainer.json"];
8
+ export class DevcontainerError extends Error {
9
+ kind;
10
+ fix;
11
+ file;
12
+ constructor(kind, message, fix, file) {
13
+ super(message);
14
+ this.name = "DevcontainerError";
15
+ this.kind = kind;
16
+ this.fix = fix;
17
+ this.file = file;
18
+ }
19
+ }
20
+ const refuse = (kind, message, fix, file) => new DevcontainerError(kind, message, fix, file);
21
+ export function parseJsonc(text, file) {
22
+ let i = 0;
23
+ const where = (at) => {
24
+ let line = 1;
25
+ let column = 1;
26
+ for (let k = 0; k < at && k < text.length; k++) {
27
+ if (text[k] === "\n") {
28
+ line++;
29
+ column = 1;
30
+ }
31
+ else
32
+ column++;
33
+ }
34
+ return { line, column };
35
+ };
36
+ const fail = (at, what) => {
37
+ const p = where(at);
38
+ throw refuse("parse", `${file ?? "devcontainer.json"}:${p.line}:${p.column}: ${what}`, "Fix the file so it is JSON with comments (JSONC): quoted keys, no unquoted values, " +
39
+ "comments with // or /* */. Trailing commas are allowed.", file);
40
+ };
41
+ const skip = () => {
42
+ for (;;) {
43
+ const c = text[i];
44
+ if (c === " " || c === "\t" || c === "\n" || c === "\r") {
45
+ i++;
46
+ continue;
47
+ }
48
+ if (c === "/" && text[i + 1] === "/") {
49
+ while (i < text.length && text[i] !== "\n")
50
+ i++;
51
+ continue;
52
+ }
53
+ if (c === "/" && text[i + 1] === "*") {
54
+ const start = i;
55
+ i += 2;
56
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
57
+ i++;
58
+ if (i >= text.length)
59
+ fail(start, "unterminated /* comment");
60
+ i += 2;
61
+ continue;
62
+ }
63
+ return;
64
+ }
65
+ };
66
+ const string = () => {
67
+ const start = i;
68
+ i++;
69
+ let out = "";
70
+ for (;;) {
71
+ if (i >= text.length)
72
+ fail(start, "unterminated string");
73
+ const c = text[i];
74
+ if (c === '"') {
75
+ i++;
76
+ return out;
77
+ }
78
+ if (c === "\n")
79
+ fail(i, "a newline inside a string; use \\n");
80
+ if (c !== "\\") {
81
+ out += c;
82
+ i++;
83
+ continue;
84
+ }
85
+ const esc = text[i + 1];
86
+ i += 2;
87
+ if (esc === "u") {
88
+ const hex = text.slice(i, i + 4);
89
+ if (!/^[0-9a-fA-F]{4}$/.test(hex))
90
+ fail(i - 2, "a \\u escape that is not four hex digits");
91
+ out += String.fromCharCode(parseInt(hex, 16));
92
+ i += 4;
93
+ continue;
94
+ }
95
+ const simple = { '"': '"', "\\": "\\", "/": "/", b: "\b", f: "\f", n: "\n", r: "\r", t: "\t" };
96
+ const mapped = esc === undefined ? undefined : simple[esc];
97
+ if (mapped === undefined)
98
+ fail(i - 2, `an unknown escape \\${esc ?? "<end of file>"}`);
99
+ out += mapped;
100
+ }
101
+ };
102
+ const value = () => {
103
+ skip();
104
+ if (i >= text.length)
105
+ fail(i, "the file ended where a value was expected");
106
+ const c = text[i];
107
+ if (c === '"')
108
+ return string();
109
+ if (c === "{") {
110
+ i++;
111
+ const out = {};
112
+ for (;;) {
113
+ skip();
114
+ if (text[i] === "}") {
115
+ i++;
116
+ return out;
117
+ }
118
+ if (text[i] !== '"')
119
+ fail(i, `a key must be a quoted string, got ${JSON.stringify(text[i] ?? "<end of file>")}`);
120
+ const key = string();
121
+ skip();
122
+ if (text[i] !== ":")
123
+ fail(i, `expected ':' after the key ${JSON.stringify(key)}`);
124
+ i++;
125
+ out[key] = value();
126
+ skip();
127
+ if (text[i] === ",") {
128
+ i++;
129
+ continue;
130
+ }
131
+ if (text[i] === "}") {
132
+ i++;
133
+ return out;
134
+ }
135
+ fail(i, `expected ',' or '}' after the value of ${JSON.stringify(key)}`);
136
+ }
137
+ }
138
+ if (c === "[") {
139
+ i++;
140
+ const out = [];
141
+ for (;;) {
142
+ skip();
143
+ if (text[i] === "]") {
144
+ i++;
145
+ return out;
146
+ }
147
+ out.push(value());
148
+ skip();
149
+ if (text[i] === ",") {
150
+ i++;
151
+ continue;
152
+ }
153
+ if (text[i] === "]") {
154
+ i++;
155
+ return out;
156
+ }
157
+ fail(i, "expected ',' or ']' in an array");
158
+ }
159
+ }
160
+ if (text.startsWith("true", i)) {
161
+ i += 4;
162
+ return true;
163
+ }
164
+ if (text.startsWith("false", i)) {
165
+ i += 5;
166
+ return false;
167
+ }
168
+ if (text.startsWith("null", i)) {
169
+ i += 4;
170
+ return null;
171
+ }
172
+ const num = /^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(text.slice(i));
173
+ if (num) {
174
+ i += num[0].length;
175
+ return Number(num[0]);
176
+ }
177
+ return fail(i, `${JSON.stringify(text.slice(i, i + 12))} is not a JSON value`);
178
+ };
179
+ const v = value();
180
+ skip();
181
+ if (i < text.length)
182
+ fail(i, "trailing content after the document");
183
+ return v;
184
+ }
185
+ export function discover(root) {
186
+ const found = [];
187
+ for (const name of DEVCONTAINER_NAMES) {
188
+ const p = join(root, name);
189
+ if (existsSync(p) && statSync(p).isFile())
190
+ found.push(p);
191
+ }
192
+ const dir = join(root, ".devcontainer");
193
+ if (existsSync(dir) && statSync(dir).isDirectory()) {
194
+ for (const entry of readdirSync(dir).sort()) {
195
+ const nested = join(dir, entry, "devcontainer.json");
196
+ if (existsSync(nested) && statSync(nested).isFile())
197
+ found.push(nested);
198
+ }
199
+ }
200
+ return found;
201
+ }
202
+ export function resolveGiven(pathArg) {
203
+ const p = resolve(pathArg);
204
+ if (!existsSync(p)) {
205
+ throw refuse("not_found", `no devcontainer at ${JSON.stringify(pathArg)}`, "Pass --devcontainer with the path to a devcontainer.json, or to the directory holding one.", pathArg);
206
+ }
207
+ if (statSync(p).isFile())
208
+ return p;
209
+ const direct = join(p, "devcontainer.json");
210
+ if (existsSync(direct))
211
+ return direct;
212
+ const under = discover(p);
213
+ if (under.length === 1)
214
+ return under[0];
215
+ if (under.length > 1) {
216
+ throw refuse("ambiguous", `${pathArg} holds ${under.length} devcontainer.json files: ${under.join(", ")}`, "Pass --devcontainer with the one you want.", pathArg);
217
+ }
218
+ throw refuse("not_found", `no devcontainer.json under ${JSON.stringify(pathArg)}`, "Pass --devcontainer with the path to a devcontainer.json, or to the directory holding one.", pathArg);
219
+ }
220
+ export function detectOne(root, asked = false) {
221
+ const found = discover(root);
222
+ if (found.length === 0) {
223
+ if (asked) {
224
+ throw refuse("not_found", `no devcontainer.json in ${root}`, `Run this where the repository is, or pass --devcontainer with the path. Looked for ${DEVCONTAINER_NAMES.join(", ")} and .devcontainer/<folder>/devcontainer.json.`);
225
+ }
226
+ return undefined;
227
+ }
228
+ if (found.length > 1) {
229
+ throw refuse("ambiguous", `this directory declares ${found.length} devcontainers: ${found.map((f) => f.slice(root.length + 1)).join(", ")}`, "Pass --devcontainer with the one to build, or --no-devcontainer to ignore them.");
230
+ }
231
+ return found[0];
232
+ }
233
+ export function workspaceRootFor(file) {
234
+ const dir = resolve(file, "..");
235
+ if (basename(dir) === ".devcontainer")
236
+ return resolve(dir, "..");
237
+ if (basename(resolve(dir, "..")) === ".devcontainer")
238
+ return resolve(dir, "..", "..");
239
+ return dir;
240
+ }
241
+ export function load(file) {
242
+ const path = resolve(file);
243
+ let text;
244
+ try {
245
+ text = readFileSync(path, "utf8");
246
+ }
247
+ catch (e) {
248
+ throw refuse("not_found", `cannot read ${file}: ${e instanceof Error ? e.message : String(e)}`, "Check the path passed to --devcontainer.", file);
249
+ }
250
+ const parsed = parseJsonc(text, file);
251
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
252
+ throw refuse("parse", `${file} is not a JSON object`, "A devcontainer.json is an object: { \"image\": \"…\" }.", file);
253
+ }
254
+ return { path, dir: resolve(path, ".."), config: parsed };
255
+ }
256
+ const VARIABLE = /\$\{([a-zA-Z]+)(?::([^}]*))?\}/g;
257
+ export function substituteString(input, ctx) {
258
+ return input.replace(VARIABLE, (whole, name, arg) => {
259
+ switch (name) {
260
+ case "localWorkspaceFolder": return ctx.localWorkspaceFolder;
261
+ case "localWorkspaceFolderBasename": return ctx.localWorkspaceFolderBasename;
262
+ case "containerWorkspaceFolder": return ctx.containerWorkspaceFolder;
263
+ case "containerWorkspaceFolderBasename": return ctx.containerWorkspaceFolderBasename;
264
+ case "devcontainerId": return ctx.devcontainerId;
265
+ case "localEnv": {
266
+ const [key, fallback] = splitDefault(arg ?? "");
267
+ return ctx.localEnv[key] ?? fallback;
268
+ }
269
+ case "containerEnv": {
270
+ if (!ctx.allowContainerEnv) {
271
+ throw refuse("container_env_scope", `\${containerEnv:…} in ${ctx.property}: the spec defines it for remoteEnv only`, "Move the value into remoteEnv, or write it out literally.", ctx.file);
272
+ }
273
+ const [key] = splitDefault(arg ?? "");
274
+ return ctx.containerEnv[key] ?? whole;
275
+ }
276
+ default: return whole;
277
+ }
278
+ });
279
+ }
280
+ function splitDefault(arg) {
281
+ const cut = arg.indexOf(":");
282
+ return cut === -1 ? [arg, ""] : [arg.slice(0, cut), arg.slice(cut + 1)];
283
+ }
284
+ export function substituteDeep(value, ctx) {
285
+ if (typeof value === "string")
286
+ return substituteString(value, ctx);
287
+ if (Array.isArray(value))
288
+ return value.map((v) => substituteDeep(v, ctx));
289
+ if (value !== null && typeof value === "object") {
290
+ const out = {};
291
+ for (const [k, v] of Object.entries(value))
292
+ out[k] = substituteDeep(v, ctx);
293
+ return out;
294
+ }
295
+ return value;
296
+ }
297
+ export function devcontainerId(workspaceRoot, file) {
298
+ const digest = createHash("sha256").update(`${resolve(workspaceRoot)}\0${resolve(file)}`).digest("hex");
299
+ return digest.slice(0, 16);
300
+ }
301
+ const KNOWN = new Set([
302
+ "$schema", "name", "image", "build", "dockerComposeFile", "service", "runServices",
303
+ "containerEnv", "remoteEnv", "containerUser", "remoteUser", "updateRemoteUserUID", "userEnvProbe",
304
+ "workspaceFolder", "workspaceMount", "mounts", "runArgs", "overrideCommand", "shutdownAction",
305
+ "init", "privileged", "capAdd", "securityOpt",
306
+ "forwardPorts", "appPort", "portsAttributes", "otherPortsAttributes",
307
+ "initializeCommand", "onCreateCommand", "updateContentCommand", "postCreateCommand",
308
+ "postStartCommand", "postAttachCommand", "waitFor",
309
+ "features", "overrideFeatureInstallOrder", "customizations", "hostRequirements",
310
+ ]);
311
+ const BAKED_LIFECYCLE = ["onCreateCommand", "updateContentCommand", "postCreateCommand", "postStartCommand"];
312
+ export function translate(loaded, opts) {
313
+ const file = loaded.path;
314
+ const raw = loaded.config;
315
+ const hostEnv = opts.localEnv ?? process.env;
316
+ const readFromHost = new Set();
317
+ const localEnv = new Proxy({}, {
318
+ get: (_t, key) => {
319
+ if (typeof key !== "string")
320
+ return undefined;
321
+ const v = hostEnv[key];
322
+ if (v !== undefined && v !== "")
323
+ readFromHost.add(key);
324
+ return v;
325
+ },
326
+ });
327
+ const tiers = opts.tiers ?? TIERS;
328
+ const ignored = [];
329
+ const notes = [];
330
+ if (raw["dockerComposeFile"] !== undefined) {
331
+ const files = asStringArray(raw["dockerComposeFile"]).join(", ");
332
+ throw refuse("compose", `this devcontainer is built by Docker Compose (${files || "dockerComposeFile"})`, "A Compose project describes several services started together, and a Promigence snapshot is " +
333
+ "built from one image. Point Promigence at the service your work runs in — give it an `image` " +
334
+ "or a `build.dockerfile` in a devcontainer.json of its own — or pass --no-devcontainer and " +
335
+ "describe the environment with --image / --dockerfile.", file);
336
+ }
337
+ refuseFeatures(raw, file);
338
+ const workspaceRoot = resolve(opts.workspaceRoot);
339
+ const localWorkspaceFolderBasename = basename(workspaceRoot);
340
+ const preCtx = {
341
+ localWorkspaceFolder: workspaceRoot,
342
+ localWorkspaceFolderBasename,
343
+ containerWorkspaceFolder: "",
344
+ containerWorkspaceFolderBasename: "",
345
+ localEnv,
346
+ containerEnv: {},
347
+ devcontainerId: devcontainerId(workspaceRoot, file),
348
+ allowContainerEnv: false,
349
+ property: "workspaceFolder",
350
+ file,
351
+ };
352
+ const declaredWorkspace = typeof raw["workspaceFolder"] === "string"
353
+ ? substituteString(raw["workspaceFolder"], preCtx).trim()
354
+ : "";
355
+ const workspaceFolder = declaredWorkspace || `/workspaces/${localWorkspaceFolderBasename}`;
356
+ if (!workspaceFolder.startsWith("/")) {
357
+ throw refuse("workspace_folder", `workspaceFolder must be an absolute path, got ${JSON.stringify(workspaceFolder)}`, "Use an absolute path inside the environment, e.g. \"workspaceFolder\": \"/workspaces/app\".", file);
358
+ }
359
+ const ctxFor = (property, containerEnv, allowContainerEnv = false) => ({
360
+ ...preCtx,
361
+ containerWorkspaceFolder: workspaceFolder,
362
+ containerWorkspaceFolderBasename: basename(workspaceFolder),
363
+ containerEnv,
364
+ allowContainerEnv,
365
+ property,
366
+ });
367
+ const containerEnv = stringMap(raw["containerEnv"], "containerEnv", file, (s, p) => substituteString(s, ctxFor(p, {})));
368
+ const remoteEnv = stringMap(raw["remoteEnv"], "remoteEnv", file, (s, p) => substituteString(s, ctxFor(p, containerEnv, true)));
369
+ const env = { ...containerEnv, ...remoteEnv };
370
+ if (Object.keys(remoteEnv).length) {
371
+ notes.push("remoteEnv is applied the same way as containerEnv: both are set for every command, terminal and SSH session in the snapshot's sandboxes");
372
+ }
373
+ let source;
374
+ if (opts.imageOverride === undefined) {
375
+ source = translateSource(raw, loaded.dir, ctxFor("build", containerEnv), file);
376
+ }
377
+ else {
378
+ source = { kind: "image", image: opts.imageOverride };
379
+ notes.push(`--image replaces the source this devcontainer names${typeof raw["image"] === "string" ? ` (${raw["image"]})` : ""}`);
380
+ }
381
+ const containerUser = optionalString(raw["containerUser"], "containerUser", file);
382
+ const remoteUser = optionalString(raw["remoteUser"], "remoteUser", file);
383
+ const user = containerUser ?? remoteUser;
384
+ if (user !== undefined && !/^[a-z_][a-z0-9_-]*$/.test(user)) {
385
+ throw refuse("user", `containerUser/remoteUser ${JSON.stringify(user)} is not a user name we can use`, "Name an account that exists in the image, e.g. \"remoteUser\": \"vscode\".", file);
386
+ }
387
+ if (containerUser !== undefined && remoteUser !== undefined && containerUser !== remoteUser) {
388
+ notes.push(`containerUser ${containerUser} and remoteUser ${remoteUser} differ; the build steps run as ${containerUser}`);
389
+ }
390
+ if (raw["updateRemoteUserUID"] !== undefined) {
391
+ ignored.push({ field: "updateRemoteUserUID", why: "there is no local user to match ids with" });
392
+ }
393
+ if (raw["initializeCommand"] !== undefined) {
394
+ throw refuse("initialize_command", "initializeCommand runs on your own machine before the environment is created", "Promigence will not run a command on your machine. Run it yourself before `promigence snapshot create`, " +
395
+ "or move the work into onCreateCommand / postCreateCommand so it is part of the environment.", file);
396
+ }
397
+ const lifecycle = [];
398
+ for (const field of BAKED_LIFECYCLE) {
399
+ for (const step of lifecycleSteps(raw[field], field, ctxFor(field, containerEnv), file))
400
+ lifecycle.push(step);
401
+ }
402
+ if (raw["postAttachCommand"] !== undefined) {
403
+ ignored.push({ field: "postAttachCommand", why: "it runs when an editor attaches, and nothing attaches to a snapshot" });
404
+ }
405
+ if (raw["postStartCommand"] !== undefined) {
406
+ notes.push("postStartCommand is run once while the snapshot is built, not on every fork; use --start for a process that must be running");
407
+ }
408
+ if (raw["waitFor"] !== undefined) {
409
+ ignored.push({ field: "waitFor", why: "every setup step must exit 0 before the snapshot is stored, so there is nothing to wait for" });
410
+ }
411
+ const unsupported = [];
412
+ for (const [field, why] of [
413
+ ["runArgs", "Promigence does not take container run arguments"],
414
+ ["privileged", "Promigence does not take container run arguments"],
415
+ ["capAdd", "Promigence does not take container run arguments"],
416
+ ["securityOpt", "Promigence does not take container run arguments"],
417
+ ["init", "Promigence does not take container run arguments"],
418
+ ["workspaceMount", "Promigence does not mount your working tree into the environment; a snapshot holds what the image and the setup steps produced"],
419
+ ["mounts", "Promigence does not mount paths from your machine into the environment"],
420
+ ]) {
421
+ if (raw[field] === undefined || raw[field] === null)
422
+ continue;
423
+ if ((field === "privileged" || field === "init") && raw[field] === false)
424
+ continue;
425
+ if (Array.isArray(raw[field]) && raw[field].length === 0)
426
+ continue;
427
+ unsupported.push(`${field} (${why})`);
428
+ }
429
+ if (unsupported.length) {
430
+ throw refuse("unsupported_property", `this devcontainer sets ${unsupported.length} propert${unsupported.length === 1 ? "y" : "ies"} a snapshot cannot take: ${unsupported.join("; ")}`, "Remove them from the devcontainer, or pass --no-devcontainer and build the environment with --image / --dockerfile and --setup.", file);
431
+ }
432
+ const ports = [...portList(raw["forwardPorts"], "forwardPorts", file), ...portList(raw["appPort"], "appPort", file)]
433
+ .filter((p, idx, all) => all.indexOf(p) === idx);
434
+ const { tier, tierReason } = chooseTier(raw["hostRequirements"], tiers, file, ignored);
435
+ for (const [field, why] of [
436
+ ["customizations", "editor and tool settings do not change what the environment contains"],
437
+ ["portsAttributes", "labels and auto-forward behaviour belong to an editor"],
438
+ ["otherPortsAttributes", "labels and auto-forward behaviour belong to an editor"],
439
+ ["userEnvProbe", "the environment your commands run in is the one the setup steps left"],
440
+ ["overrideCommand", "a snapshot has no keep-alive command to override"],
441
+ ["shutdownAction", "a sandbox ends on its own timeout, or when you end it"],
442
+ ["$schema", "an editor hint"],
443
+ ]) {
444
+ if (raw[field] !== undefined)
445
+ ignored.push({ field, why });
446
+ }
447
+ for (const key of Object.keys(raw)) {
448
+ if (!KNOWN.has(key))
449
+ ignored.push({ field: key, why: "not a property of the Dev Container specification this version reads" });
450
+ }
451
+ if (readFromHost.size > 0 && opts.allowLocalEnv !== true) {
452
+ const names = [...readFromHost].sort();
453
+ throw refuse("local_env", `this devcontainer reads ${names.length === 1 ? "a value" : `${names.length} values`} from your environment (\${localEnv:${names.join("}, ${localEnv:")}}), ` +
454
+ "and a value read here is printed, stored with the build and baked into every sandbox forked from it", "Pass --devcontainer-local-env to bake them in anyway; for a token, store it with `promigence secrets set NAME` " +
455
+ "and pass it at run time (--secret NAME), or unset the variable for this command.", file);
456
+ }
457
+ return {
458
+ spec: DEVCONTAINER_SPEC,
459
+ file,
460
+ name: optionalString(raw["name"], "name", file),
461
+ source,
462
+ env,
463
+ workspaceFolder,
464
+ user,
465
+ ...(remoteUser === undefined ? {} : { remoteUser }),
466
+ lifecycle,
467
+ ports,
468
+ tier,
469
+ tierReason,
470
+ ignored,
471
+ notes,
472
+ ...(readFromHost.size > 0 ? { localEnvBaked: [...readFromHost].sort() } : {}),
473
+ };
474
+ }
475
+ function refuseFeatures(raw, file) {
476
+ const features = raw["features"];
477
+ if (features === undefined || features === null)
478
+ return;
479
+ if (typeof features !== "object" || Array.isArray(features)) {
480
+ throw refuse("features", "features must be an object of Feature ids", "See containers.dev/implementors/features.", file);
481
+ }
482
+ const ids = Object.keys(features);
483
+ if (ids.length === 0)
484
+ return;
485
+ throw refuse("features", `this devcontainer installs ${ids.length} Dev Container Feature${ids.length === 1 ? "" : "s"}, which Promigence does not install: ${ids.join(", ")}`, "A Feature that quietly did not install is worse than a refused build, so we refuse. " +
486
+ "Either use an image that already carries these tools, or write the install commands the " +
487
+ "Features would have run as `postCreateCommand` (or `promigence snapshot create --setup`), " +
488
+ "then rebuild. --no-devcontainer ignores the file entirely.", file);
489
+ }
490
+ function translateSource(raw, dir, ctx, file) {
491
+ const image = optionalString(raw["image"], "image", file);
492
+ const build = raw["build"];
493
+ if (image !== undefined && build !== undefined) {
494
+ throw refuse("source", "this devcontainer sets both image and build", "Keep one of them: an image to pull, or a Dockerfile to build.", file);
495
+ }
496
+ if (image !== undefined) {
497
+ const pinned = substituteString(image, ctx).trim();
498
+ if (!/^\S+@sha256:[0-9a-f]{64}$/.test(pinned)) {
499
+ throw refuse("image_unpinned", `the image ${JSON.stringify(pinned)} is not pinned by digest`, "A tag can be moved under you, so a snapshot built from one cannot be rebuilt. Resolve it once " +
500
+ `with \`${sh `promigence image resolve ${pinned}`}\` (no local docker needed) and either write ` +
501
+ "the name@sha256:<digest> it prints in the devcontainer.json or pass --image with it.", file);
502
+ }
503
+ return { kind: "image", image: pinned };
504
+ }
505
+ if (build === undefined) {
506
+ throw refuse("no_source", "this devcontainer names neither an image nor a Dockerfile", "Add \"image\": \"name@sha256:<digest>\" or \"build\": { \"dockerfile\": \"Dockerfile\" } to the devcontainer.json.", file);
507
+ }
508
+ if (typeof build !== "object" || build === null || Array.isArray(build)) {
509
+ throw refuse("source", "build must be an object", "Use \"build\": { \"dockerfile\": \"Dockerfile\" }.", file);
510
+ }
511
+ const b = build;
512
+ for (const field of ["args", "target", "cacheFrom", "options"]) {
513
+ const v = b[field];
514
+ if (v === undefined || v === null)
515
+ continue;
516
+ if (Array.isArray(v) && v.length === 0)
517
+ continue;
518
+ if (typeof v === "object" && !Array.isArray(v) && Object.keys(v).length === 0)
519
+ continue;
520
+ throw refuse("build_option", `build.${field} is set, and a Promigence Dockerfile build does not take it yet`, `Move the value into the Dockerfile itself (an ARG with a default, or the FROM stage you want), ` +
521
+ `or pass --no-devcontainer and build with --dockerfile.`, file);
522
+ }
523
+ const dockerfile = optionalString(b["dockerfile"], "build.dockerfile", file);
524
+ if (dockerfile === undefined) {
525
+ throw refuse("source", "build.dockerfile is required", "Use \"build\": { \"dockerfile\": \"Dockerfile\" }.", file);
526
+ }
527
+ const contextRel = optionalString(b["context"], "build.context", file) ?? ".";
528
+ const dfPath = substituteString(dockerfile, ctx);
529
+ const ctxPath = substituteString(contextRel, ctx);
530
+ const out = {
531
+ kind: "dockerfile",
532
+ dockerfile: isAbsolute(dfPath) ? dfPath : resolve(dir, dfPath),
533
+ context: isAbsolute(ctxPath) ? ctxPath : resolve(dir, ctxPath),
534
+ };
535
+ const root = resolve(ctx.localWorkspaceFolder);
536
+ for (const [what, p] of [["build.context", out.context], ["build.dockerfile", out.dockerfile]]) {
537
+ const rel = relative(root, p);
538
+ if (rel.startsWith("..") || isAbsolute(rel)) {
539
+ throw refuse("context_outside", `${what} ${JSON.stringify(p)} is outside the workspace ${root}, and everything in a build context is uploaded`, "Point it at a directory inside the repository, or pass --no-devcontainer and name --dockerfile/--context yourself.", file);
540
+ }
541
+ }
542
+ return out;
543
+ }
544
+ export const OPT_IN_TIERS = new Set(["xsmall", "large-16"]);
545
+ function chooseTier(value, tiers, file, ignored) {
546
+ if (value === undefined || value === null)
547
+ return { tier: undefined, tierReason: undefined };
548
+ if (typeof value !== "object" || Array.isArray(value)) {
549
+ throw refuse("host_requirements", "hostRequirements must be an object", "Use \"hostRequirements\": { \"cpus\": 4, \"memory\": \"8gb\" }.", file);
550
+ }
551
+ const h = value;
552
+ if (h["gpu"] !== undefined && h["gpu"] !== false && h["gpu"] !== "optional") {
553
+ throw refuse("gpu", "hostRequirements.gpu asks for a GPU", "Promigence has no GPU shapes. Drop the requirement, or write to support@promigence.ai.", file);
554
+ }
555
+ if (h["gpu"] === "optional" || h["gpu"] === false)
556
+ ignored.push({ field: "hostRequirements.gpu", why: "no GPU was required" });
557
+ const cpus = h["cpus"] === undefined ? 0 : positiveInt(h["cpus"], "hostRequirements.cpus", file);
558
+ const memGib = h["memory"] === undefined ? 0 : sizeGib(h["memory"], "hostRequirements.memory", file);
559
+ const diskGib = h["storage"] === undefined ? 0 : sizeGib(h["storage"], "hostRequirements.storage", file);
560
+ if (cpus === 0 && memGib === 0 && diskGib === 0)
561
+ return { tier: undefined, tierReason: undefined };
562
+ const sellable = tiers.filter((t) => t.sellable && !OPT_IN_TIERS.has(t.name)).sort((a, b) => a.vcpus - b.vcpus || a.mem_gib - b.mem_gib);
563
+ const fit = sellable.find((t) => t.vcpus >= cpus && t.mem_gib >= memGib && t.disk_gib >= diskGib);
564
+ const asked = [
565
+ cpus ? `${cpus} cpus` : "",
566
+ memGib ? `${trim(memGib)} GiB memory` : "",
567
+ diskGib ? `${trim(diskGib)} GiB storage` : "",
568
+ ].filter(Boolean).join(", ");
569
+ if (!fit) {
570
+ const biggest = sellable[sellable.length - 1];
571
+ throw refuse("host_requirements", `hostRequirements asks for ${asked}, and the largest tier is ${biggest ? `${biggest.name} (${biggest.vcpus} vCPU, ${biggest.mem_gib} GiB, ${biggest.disk_gib} GiB disk)` : "smaller"}`, "Lower hostRequirements, or write to support@promigence.ai about a larger shape.", file);
572
+ }
573
+ return { tier: fit.name, tierReason: `hostRequirements asks for ${asked}; ${fit.name} is the smallest tier that covers it` };
574
+ }
575
+ function optionalString(v, field, file) {
576
+ if (v === undefined || v === null)
577
+ return undefined;
578
+ if (typeof v !== "string") {
579
+ throw refuse("type", `${field} must be a string, got ${JSON.stringify(v)}`, `Give ${field} a string value.`, file);
580
+ }
581
+ const t = v.trim();
582
+ return t === "" ? undefined : t;
583
+ }
584
+ function asStringArray(v) {
585
+ if (typeof v === "string")
586
+ return [v];
587
+ if (Array.isArray(v))
588
+ return v.filter((x) => typeof x === "string");
589
+ return [];
590
+ }
591
+ function stringMap(v, field, file, sub) {
592
+ if (v === undefined || v === null)
593
+ return {};
594
+ if (typeof v !== "object" || Array.isArray(v)) {
595
+ throw refuse("type", `${field} must be an object of NAME: value`, `Use "${field}": { "NODE_ENV": "test" }.`, file);
596
+ }
597
+ const out = {};
598
+ for (const [k, raw] of Object.entries(v)) {
599
+ if (typeof raw !== "string") {
600
+ throw refuse("type", `${field}.${k} must be a string, got ${JSON.stringify(raw)}`, `Quote the value: "${k}": "${String(raw)}".`, file);
601
+ }
602
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) {
603
+ throw refuse("type", `${field}.${k} is not a usable environment variable name`, "Use letters, digits and underscores, not starting with a digit.", file);
604
+ }
605
+ out[k] = sub(raw, `${field}.${k}`);
606
+ }
607
+ return out;
608
+ }
609
+ function positiveInt(v, field, file) {
610
+ if (typeof v !== "number" || !Number.isInteger(v) || v < 1) {
611
+ throw refuse("type", `${field} must be a whole number of 1 or more, got ${JSON.stringify(v)}`, `Use "${field}": 4.`, file);
612
+ }
613
+ return v;
614
+ }
615
+ export function sizeGib(v, field, file) {
616
+ if (typeof v === "number" && Number.isFinite(v) && v >= 0)
617
+ return v / 1024 ** 3;
618
+ if (typeof v !== "string") {
619
+ throw refuse("type", `${field} must be a size like "8gb", got ${JSON.stringify(v)}`, `Use "${field}": "8gb" (kb, mb, gb, tb).`, file);
620
+ }
621
+ const m = /^(\d+)\s*([tgmk]b)?$/i.exec(v.trim());
622
+ if (!m) {
623
+ throw refuse("type", `${field} ${JSON.stringify(v)} is not a size`, `Use a number with kb, mb, gb or tb, e.g. "${field}": "8gb".`, file);
624
+ }
625
+ const n = Number(m[1]);
626
+ const unit = (m[2] ?? "").toLowerCase();
627
+ const bytes = unit === "kb" ? n * 1024 : unit === "mb" ? n * 1024 ** 2 : unit === "gb" ? n * 1024 ** 3 : unit === "tb" ? n * 1024 ** 4 : n;
628
+ return bytes / 1024 ** 3;
629
+ }
630
+ const trim = (n) => (Number.isInteger(n) ? String(n) : n.toFixed(2).replace(/0+$/, "").replace(/\.$/, ""));
631
+ function portList(v, field, file) {
632
+ if (v === undefined || v === null)
633
+ return [];
634
+ const items = Array.isArray(v) ? v : [v];
635
+ const out = [];
636
+ for (const item of items) {
637
+ if (typeof item === "number" && Number.isInteger(item) && item >= 0 && item <= 65535) {
638
+ out.push(item);
639
+ continue;
640
+ }
641
+ if (typeof item === "string") {
642
+ const m = /^(?:[^:]*:)?(\d{1,5})$/.exec(item.trim());
643
+ if (m && Number(m[1]) <= 65535) {
644
+ out.push(Number(m[1]));
645
+ continue;
646
+ }
647
+ }
648
+ throw refuse("type", `${field} entry ${JSON.stringify(item)} is not a port`, `Use a number, or "host:port", e.g. "${field}": [3000].`, file);
649
+ }
650
+ return out;
651
+ }
652
+ export function lifecycleSteps(v, field, ctx, file) {
653
+ if (v === undefined || v === null)
654
+ return [];
655
+ const one = (value, label) => {
656
+ if (typeof value === "string") {
657
+ const cmd = substituteString(value, ctx).trim();
658
+ if (!cmd)
659
+ throw refuse("type", `${field}${label ? `.${label}` : ""} is empty`, `Give ${field} a command, or remove it.`, file);
660
+ return label === undefined ? { field, command: cmd } : { field, command: cmd, label };
661
+ }
662
+ if (Array.isArray(value)) {
663
+ if (value.length === 0 || !value.every((x) => typeof x === "string")) {
664
+ throw refuse("type", `${field}${label ? `.${label}` : ""} must be an array of strings`, `Use ["npm", "install"].`, file);
665
+ }
666
+ const cmd = value.map((x) => shellQuote(substituteString(x, ctx))).join(" ");
667
+ return label === undefined ? { field, command: cmd } : { field, command: cmd, label };
668
+ }
669
+ throw refuse("type", `${field}${label ? `.${label}` : ""} must be a string, an array or an object`, "See containers.dev/implementors/json_reference.", file);
670
+ };
671
+ if (typeof v === "object" && !Array.isArray(v)) {
672
+ return Object.entries(v).map(([label, value]) => one(value, label));
673
+ }
674
+ return [one(v)];
675
+ }
676
+ export function shellQuote(s) {
677
+ return `'${s.replace(/'/g, `'\\''`)}'`;
678
+ }
679
+ export function wrapCommand(plan, command) {
680
+ const inner = [`cd ${shellQuote(plan.workspaceFolder)}`, command].join(" && ");
681
+ return plan.user === undefined || plan.user === "root" ? inner : `su ${plan.user} -c ${shellQuote(inner)}`;
682
+ }
683
+ export function snapshotEnv(plan) {
684
+ return Object.keys(plan.env).length ? { ...plan.env } : undefined;
685
+ }
686
+ export function snapshotUsers(plan) {
687
+ const user = plan.remoteUser ?? plan.user;
688
+ return user === undefined ? undefined : { user, setupUser: "root" };
689
+ }
690
+ export function planSetup(plan, opts = {}) {
691
+ const steps = [];
692
+ if (opts.repo && opts.commit) {
693
+ const ws = shellQuote(plan.workspaceFolder);
694
+ const clone = [
695
+ `mkdir -p ${shellQuote(resolve(plan.workspaceFolder, ".."))}`,
696
+ `git clone ${shellQuote(opts.repo)} ${ws}`,
697
+ `git -C ${ws} checkout -q ${shellQuote(opts.commit)}`,
698
+ ...(plan.user && plan.user !== "root" ? [`chown -R ${plan.user} ${ws}`] : []),
699
+ ].join(" && ");
700
+ steps.push(clone);
701
+ }
702
+ for (const step of plan.lifecycle)
703
+ steps.push(wrapCommand(plan, step.command));
704
+ for (const step of opts.userSetup ?? [])
705
+ steps.push(step);
706
+ return steps;
707
+ }
708
+ export function planJson(plan, setup) {
709
+ return {
710
+ spec: plan.spec,
711
+ file: plan.file,
712
+ ...(plan.name === undefined ? {} : { name: plan.name }),
713
+ source: plan.source,
714
+ workspace_folder: plan.workspaceFolder,
715
+ ...(plan.user === undefined ? {} : { user: plan.user }),
716
+ env: plan.env,
717
+ ports: plan.ports,
718
+ ...(plan.tier === undefined ? {} : { tier: plan.tier, tier_reason: plan.tierReason }),
719
+ lifecycle: plan.lifecycle.map((s) => ({ field: s.field, ...(s.label === undefined ? {} : { label: s.label }), command: s.command })),
720
+ setup,
721
+ ignored: plan.ignored,
722
+ notes: plan.notes,
723
+ };
724
+ }
725
+ export function planLines(plan, setup) {
726
+ const lines = [`devcontainer: ${plan.file}${plan.name ? ` (${plan.name})` : ""}`];
727
+ lines.push(plan.source.kind === "image"
728
+ ? ` image ${plan.source.image}`
729
+ : ` build ${plan.source.dockerfile} (context ${plan.source.context})`);
730
+ lines.push(` workspace ${plan.workspaceFolder}${plan.user ? ` · user ${plan.user}` : ""}`);
731
+ if (plan.tier)
732
+ lines.push(` tier ${plan.tier} — ${plan.tierReason ?? ""}`);
733
+ const envKeys = Object.keys(plan.env);
734
+ if (envKeys.length)
735
+ lines.push(` env ${envKeys.join(", ")}`);
736
+ if (plan.ports.length)
737
+ lines.push(` ports ${plan.ports.join(", ")} (expose one with: promigence port expose PORT --sandbox SID)`);
738
+ setup.forEach((s, idx) => lines.push(` setup[${idx}] ${s}`));
739
+ for (const ig of plan.ignored)
740
+ lines.push(` ignored ${ig.field}: ${ig.why}`);
741
+ for (const n of plan.notes)
742
+ lines.push(` note ${n}`);
743
+ return lines;
744
+ }