pulumi-sandbox 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +282 -0
  3. package/dist/actions.d.ts +7 -0
  4. package/dist/actions.d.ts.map +1 -0
  5. package/dist/actions.js +14 -0
  6. package/dist/backend.d.ts +40 -0
  7. package/dist/backend.d.ts.map +1 -0
  8. package/dist/backend.js +40 -0
  9. package/dist/docker/exec.d.ts +22 -0
  10. package/dist/docker/exec.d.ts.map +1 -0
  11. package/dist/docker/exec.js +26 -0
  12. package/dist/docker/index.d.ts +12 -0
  13. package/dist/docker/index.d.ts.map +1 -0
  14. package/dist/docker/index.js +8 -0
  15. package/dist/docker/mask-volumes.d.ts +74 -0
  16. package/dist/docker/mask-volumes.d.ts.map +1 -0
  17. package/dist/docker/mask-volumes.js +97 -0
  18. package/dist/docker/shell.d.ts +23 -0
  19. package/dist/docker/shell.d.ts.map +1 -0
  20. package/dist/docker/shell.js +34 -0
  21. package/dist/environment-file.d.ts +58 -0
  22. package/dist/environment-file.d.ts.map +1 -0
  23. package/dist/environment-file.js +129 -0
  24. package/dist/errors.d.ts +43 -0
  25. package/dist/errors.d.ts.map +1 -0
  26. package/dist/errors.js +47 -0
  27. package/dist/git.d.ts +11 -0
  28. package/dist/git.d.ts.map +1 -0
  29. package/dist/git.js +24 -0
  30. package/dist/identity.d.ts +55 -0
  31. package/dist/identity.d.ts.map +1 -0
  32. package/dist/identity.js +94 -0
  33. package/dist/index.d.ts +28 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +19 -0
  36. package/dist/interactive.d.ts +15 -0
  37. package/dist/interactive.d.ts.map +1 -0
  38. package/dist/interactive.js +45 -0
  39. package/dist/lifecycle.d.ts +41 -0
  40. package/dist/lifecycle.d.ts.map +1 -0
  41. package/dist/lifecycle.js +110 -0
  42. package/dist/outputs.d.ts +30 -0
  43. package/dist/outputs.d.ts.map +1 -0
  44. package/dist/outputs.js +65 -0
  45. package/dist/readiness.d.ts +57 -0
  46. package/dist/readiness.d.ts.map +1 -0
  47. package/dist/readiness.js +66 -0
  48. package/dist/sandbox.d.ts +172 -0
  49. package/dist/sandbox.d.ts.map +1 -0
  50. package/dist/sandbox.js +260 -0
  51. package/dist/state-surgery.d.ts +34 -0
  52. package/dist/state-surgery.d.ts.map +1 -0
  53. package/dist/state-surgery.js +94 -0
  54. package/dist/terminal.d.ts +35 -0
  55. package/dist/terminal.d.ts.map +1 -0
  56. package/dist/terminal.js +67 -0
  57. package/package.json +59 -0
@@ -0,0 +1,97 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs";
3
+ import { warn } from "../terminal.js";
4
+ /**
5
+ * Walks a repository and returns one container-local mask volume for every
6
+ * directory the given rules mark as locally generated — directories whose
7
+ * contents must not round-trip between the host bind mount and the
8
+ * container, which is essential when host and container run different
9
+ * platforms (a Windows host building inside a Linux container, for example).
10
+ *
11
+ * Detection keys off the marker files — which exist from the first
12
+ * checkout — never off the masked directories themselves, so a directory
13
+ * that does not exist yet is still masked: docker creates the empty volume
14
+ * on first start and the first in-container build writes there, never back
15
+ * to the host. Add a module to the repository and its masks appear on the
16
+ * next `create`, with no list to maintain.
17
+ */
18
+ export function discoverMaskVolumes(rootDirectory, options) {
19
+ const root = path.resolve(rootDirectory);
20
+ const prune = new Set(options.prune ?? []);
21
+ const volumes = [];
22
+ const seenContainerPaths = new Set();
23
+ const containerPathByKey = new Map();
24
+ const uniqueKey = (prefix, relativeDirectory, leaf, containerPath) => {
25
+ const base = `${prefix}--${slug(relativeDirectory)}`;
26
+ const candidates = [base, `${base}--${slug(leaf)}`];
27
+ for (const candidate of candidates) {
28
+ if (!containerPathByKey.has(candidate)) {
29
+ return candidate;
30
+ }
31
+ }
32
+ let ordinal = 2;
33
+ while (containerPathByKey.has(`${candidates[1]}-${ordinal}`)) {
34
+ ordinal += 1;
35
+ }
36
+ return `${candidates[1]}-${ordinal}`;
37
+ };
38
+ const mask = (prefix, absoluteDirectory, leaf) => {
39
+ const absoluteMasked = path.resolve(absoluteDirectory, leaf);
40
+ const relativeMasked = path.relative(root, absoluteMasked);
41
+ if (relativeMasked.startsWith("..") || path.isAbsolute(relativeMasked)) {
42
+ warn(`Cannot mask ${absoluteMasked}: it is outside the walked root ${root}.`);
43
+ return;
44
+ }
45
+ const containerPath = path.posix.join(options.containerRoot, relativeMasked.replace(/\\/g, "/"));
46
+ if (seenContainerPaths.has(containerPath)) {
47
+ return;
48
+ }
49
+ seenContainerPaths.add(containerPath);
50
+ const key = uniqueKey(prefix, path.dirname(relativeMasked), path.basename(relativeMasked), containerPath);
51
+ containerPathByKey.set(key, containerPath);
52
+ volumes.push({ key, containerPath });
53
+ };
54
+ const walk = (absoluteDirectory) => {
55
+ // Children masked in THIS directory are not walked into; an unrelated
56
+ // directory elsewhere that shares the name still is.
57
+ const maskedChildren = new Set();
58
+ for (const rule of options.rules) {
59
+ const markerPath = firstExisting(absoluteDirectory, rule.markers);
60
+ if (markerPath === undefined) {
61
+ continue;
62
+ }
63
+ for (const leaf of rule.mask) {
64
+ mask(rule.prefix, absoluteDirectory, leaf);
65
+ const [firstSegment, ...restSegments] = leaf.split("/");
66
+ if (firstSegment !== undefined && restSegments.length === 0) {
67
+ maskedChildren.add(firstSegment);
68
+ }
69
+ }
70
+ if (rule.expand) {
71
+ const content = fs.readFileSync(markerPath, "utf-8");
72
+ for (const expanded of rule.expand(markerPath, content)) {
73
+ for (const leaf of rule.mask) {
74
+ mask(rule.prefix, path.resolve(absoluteDirectory, expanded), leaf);
75
+ }
76
+ }
77
+ }
78
+ }
79
+ for (const entry of fs.readdirSync(absoluteDirectory, { withFileTypes: true })) {
80
+ if (entry.isDirectory() &&
81
+ !maskedChildren.has(entry.name) &&
82
+ !prune.has(entry.name) &&
83
+ !entry.name.startsWith(".")) {
84
+ walk(path.join(absoluteDirectory, entry.name));
85
+ }
86
+ }
87
+ };
88
+ walk(root);
89
+ return volumes;
90
+ }
91
+ function slug(relativeDirectory) {
92
+ const normalized = relativeDirectory.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
93
+ return normalized === "" || normalized === "." ? "root" : normalized.replace(/[^a-zA-Z0-9]+/g, "-").toLowerCase();
94
+ }
95
+ function firstExisting(directory, names) {
96
+ return names.map((name) => path.join(directory, name)).find((candidate) => fs.existsSync(candidate));
97
+ }
@@ -0,0 +1,23 @@
1
+ export interface AttachShellOptions {
2
+ /** Shell to start inside the container. Default: `/bin/bash`. */
3
+ shell?: string | undefined;
4
+ }
5
+ /**
6
+ * Attaches an interactive shell to a running container — the
7
+ * `docker exec -it <container> <shell>` a developer would type by hand.
8
+ * Returns the shell's exit code so the caller decides what to do with the
9
+ * process.
10
+ *
11
+ * Pairs naturally with a custom sandbox command:
12
+ *
13
+ * ```typescript
14
+ * commands: {
15
+ * shell: {
16
+ * description: "Open a shell inside the workspace container",
17
+ * run: ({ physicalName, argv }) => attachShell(physicalName("workspace"), { shell: argv[0] }),
18
+ * },
19
+ * }
20
+ * ```
21
+ */
22
+ export declare function attachShell(containerName: string, options?: AttachShellOptions): number;
23
+ //# sourceMappingURL=shell.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shell.d.ts","sourceRoot":"","sources":["../../src/docker/shell.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,kBAAkB;IACjC,iEAAiE;IACjE,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC5B;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,WAAW,CAAC,aAAa,EAAE,MAAM,EAAE,OAAO,GAAE,kBAAuB,GAAG,MAAM,CAgB3F"}
@@ -0,0 +1,34 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { fail } from "../terminal.js";
3
+ /**
4
+ * Attaches an interactive shell to a running container — the
5
+ * `docker exec -it <container> <shell>` a developer would type by hand.
6
+ * Returns the shell's exit code so the caller decides what to do with the
7
+ * process.
8
+ *
9
+ * Pairs naturally with a custom sandbox command:
10
+ *
11
+ * ```typescript
12
+ * commands: {
13
+ * shell: {
14
+ * description: "Open a shell inside the workspace container",
15
+ * run: ({ physicalName, argv }) => attachShell(physicalName("workspace"), { shell: argv[0] }),
16
+ * },
17
+ * }
18
+ * ```
19
+ */
20
+ export function attachShell(containerName, options = {}) {
21
+ const shell = options.shell ?? "/bin/bash";
22
+ process.stdout.write(`Attaching to ${containerName} (${shell})...\n`);
23
+ const result = spawnSync("docker", ["exec", "-it", containerName, shell], { stdio: "inherit" });
24
+ if (result.error) {
25
+ fail(`Failed to run docker: ${result.error.message}`);
26
+ return 1;
27
+ }
28
+ if (result.status !== 0) {
29
+ fail(`docker exec exited with ${result.status ?? "no status"}.`, [
30
+ `Is the container running? Start it with the "create" action.`,
31
+ ]);
32
+ }
33
+ return result.status ?? 0;
34
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * A value renderable into an environment file. Strings pass through,
3
+ * numbers and booleans are stringified, and plain objects or arrays are
4
+ * JSON-serialized — convenient for variables that carry structured
5
+ * configuration (service discovery maps, feature matrices).
6
+ */
7
+ export type EnvironmentValue = string | number | boolean | object;
8
+ /** A group of related variables, rendered together and separated from other groups by a blank line. */
9
+ export type EnvironmentValues = Record<string, EnvironmentValue>;
10
+ /**
11
+ * An ordered, incrementally-built `.env` file.
12
+ *
13
+ * Programs accumulate variables as the infrastructure comes together — static
14
+ * ports first, then connection strings, then values that only exist once
15
+ * Pulumi outputs resolve — and render the file at the end:
16
+ *
17
+ * ```typescript
18
+ * const env = new EnvironmentFile([{ APP_HTTP_PORT: 8080 }]);
19
+ * env.add({ DATABASE_URL: `postgresql://localhost:${db.hostPort}/app` });
20
+ * env.write("application/.env.local");
21
+ * ```
22
+ *
23
+ * Semantics:
24
+ * - Insertion order is preserved; each {@link add} call starts a new group
25
+ * separated from the previous one by a blank line.
26
+ * - Re-adding an existing key overwrites its value in place, so the file
27
+ * never contains duplicate keys and the layout stays stable.
28
+ * - An empty string renders as a commented-out `# KEY=` line — present for
29
+ * discoverability, inactive for the loader.
30
+ * - `undefined` and `null` throw {@link EnvironmentFileError} immediately:
31
+ * they are always a sign of an unresolved output or missing configuration,
32
+ * and a loud failure beats a poisoned environment file.
33
+ */
34
+ export declare class EnvironmentFile {
35
+ #private;
36
+ constructor(groups?: readonly EnvironmentValues[]);
37
+ /**
38
+ * Adds a group of variables, overwriting values for keys that already
39
+ * exist and appending the rest as a new blank-line-separated group.
40
+ */
41
+ add(values: EnvironmentValues): this;
42
+ /** Starts a new group; consecutive separators collapse into one blank line. */
43
+ addSeparator(): this;
44
+ /** Whether a variable has been declared. */
45
+ has(key: string): boolean;
46
+ /**
47
+ * The effective variables, each rendered to its final string form.
48
+ * Mirrors {@link render}: keys whose value is the empty string are
49
+ * commented out in the file and therefore omitted here, so both
50
+ * consumption paths observe the same environment.
51
+ */
52
+ values(): Record<string, string>;
53
+ /** Renders the file content, ending with a newline. */
54
+ render(): string;
55
+ /** Renders and writes the file, creating parent directories as needed. */
56
+ write(filePath: string): void;
57
+ }
58
+ //# sourceMappingURL=environment-file.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"environment-file.d.ts","sourceRoot":"","sources":["../src/environment-file.ts"],"names":[],"mappings":"AAIA;;;;;GAKG;AACH,MAAM,MAAM,gBAAgB,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,MAAM,CAAC;AAElE,uGAAuG;AACvG,MAAM,MAAM,iBAAiB,GAAG,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC;AAMjE;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,qBAAa,eAAe;;gBAId,MAAM,GAAE,SAAS,iBAAiB,EAAO;IAMrD;;;OAGG;IACH,GAAG,CAAC,MAAM,EAAE,iBAAiB,GAAG,IAAI;IAuBpC,+EAA+E;IAC/E,YAAY,IAAI,IAAI;IAOpB,4CAA4C;IAC5C,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO;IAIzB;;;;;OAKG;IACH,MAAM,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC;IAWhC,uDAAuD;IACvD,MAAM,IAAI,MAAM;IAWhB,0EAA0E;IAC1E,KAAK,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI;CA2B9B"}
@@ -0,0 +1,129 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { EnvironmentFileError } from "./errors.js";
4
+ const KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
5
+ /**
6
+ * An ordered, incrementally-built `.env` file.
7
+ *
8
+ * Programs accumulate variables as the infrastructure comes together — static
9
+ * ports first, then connection strings, then values that only exist once
10
+ * Pulumi outputs resolve — and render the file at the end:
11
+ *
12
+ * ```typescript
13
+ * const env = new EnvironmentFile([{ APP_HTTP_PORT: 8080 }]);
14
+ * env.add({ DATABASE_URL: `postgresql://localhost:${db.hostPort}/app` });
15
+ * env.write("application/.env.local");
16
+ * ```
17
+ *
18
+ * Semantics:
19
+ * - Insertion order is preserved; each {@link add} call starts a new group
20
+ * separated from the previous one by a blank line.
21
+ * - Re-adding an existing key overwrites its value in place, so the file
22
+ * never contains duplicate keys and the layout stays stable.
23
+ * - An empty string renders as a commented-out `# KEY=` line — present for
24
+ * discoverability, inactive for the loader.
25
+ * - `undefined` and `null` throw {@link EnvironmentFileError} immediately:
26
+ * they are always a sign of an unresolved output or missing configuration,
27
+ * and a loud failure beats a poisoned environment file.
28
+ */
29
+ export class EnvironmentFile {
30
+ #lines = [];
31
+ #entries = new Map();
32
+ constructor(groups = []) {
33
+ for (const group of groups) {
34
+ this.add(group);
35
+ }
36
+ }
37
+ /**
38
+ * Adds a group of variables, overwriting values for keys that already
39
+ * exist and appending the rest as a new blank-line-separated group.
40
+ */
41
+ add(values) {
42
+ const additions = [];
43
+ for (const [key, value] of Object.entries(values)) {
44
+ this.#validate(key, value);
45
+ const existing = this.#entries.get(key);
46
+ if (existing) {
47
+ existing.value = value;
48
+ }
49
+ else {
50
+ additions.push([key, value]);
51
+ }
52
+ }
53
+ if (additions.length > 0) {
54
+ this.addSeparator();
55
+ for (const [key, value] of additions) {
56
+ const entry = { kind: "entry", key, value };
57
+ this.#lines.push(entry);
58
+ this.#entries.set(key, entry);
59
+ }
60
+ }
61
+ return this;
62
+ }
63
+ /** Starts a new group; consecutive separators collapse into one blank line. */
64
+ addSeparator() {
65
+ if (this.#lines.length > 0 && this.#lines.at(-1)?.kind !== "separator") {
66
+ this.#lines.push({ kind: "separator" });
67
+ }
68
+ return this;
69
+ }
70
+ /** Whether a variable has been declared. */
71
+ has(key) {
72
+ return this.#entries.has(key);
73
+ }
74
+ /**
75
+ * The effective variables, each rendered to its final string form.
76
+ * Mirrors {@link render}: keys whose value is the empty string are
77
+ * commented out in the file and therefore omitted here, so both
78
+ * consumption paths observe the same environment.
79
+ */
80
+ values() {
81
+ const rendered = {};
82
+ for (const [key, entry] of this.#entries) {
83
+ const value = renderValue(entry.key, entry.value);
84
+ if (value !== "") {
85
+ rendered[key] = value;
86
+ }
87
+ }
88
+ return rendered;
89
+ }
90
+ /** Renders the file content, ending with a newline. */
91
+ render() {
92
+ const rendered = this.#lines.map((line) => {
93
+ if (line.kind === "separator") {
94
+ return "";
95
+ }
96
+ const value = renderValue(line.key, line.value);
97
+ return value === "" ? `# ${line.key}=` : `${line.key}=${value}`;
98
+ });
99
+ return `${rendered.join("\n")}\n`;
100
+ }
101
+ /** Renders and writes the file, creating parent directories as needed. */
102
+ write(filePath) {
103
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
104
+ fs.writeFileSync(filePath, this.render());
105
+ }
106
+ #validate(key, value) {
107
+ if (!KEY_PATTERN.test(key)) {
108
+ throw new EnvironmentFileError(`"${key}" is not a valid environment variable name (letters, digits, and underscores only).`);
109
+ }
110
+ if (value === undefined || value === null) {
111
+ throw new EnvironmentFileError(`The value for "${key}" is ${String(value)} — typically an unresolved Pulumi output or missing configuration.`);
112
+ }
113
+ // Duck-typed on the marker property so this module stays free of any
114
+ // @pulumi/pulumi import; pulumi.Output.isInstance checks the same key.
115
+ if (typeof value === "object" && "__pulumiOutput" in value) {
116
+ throw new EnvironmentFileError(`The value for "${key}" is an unresolved Pulumi output — resolve it first, e.g. with deepResolve(...).apply(...).`);
117
+ }
118
+ if (typeof value === "function") {
119
+ throw new EnvironmentFileError(`The value for "${key}" is a function, which cannot be rendered.`);
120
+ }
121
+ }
122
+ }
123
+ function renderValue(key, value) {
124
+ const rendered = typeof value === "string" ? value : typeof value === "object" ? JSON.stringify(value) : String(value);
125
+ if (rendered.includes("\n")) {
126
+ throw new EnvironmentFileError(`The value for "${key}" contains a newline, which would corrupt the file.`);
127
+ }
128
+ return rendered;
129
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Base class for every error raised by the sandbox harness. Library code
3
+ * throws typed errors and never calls `process.exit` itself; the command
4
+ * line dispatcher in `sandbox()` converts them into friendly output and an
5
+ * exit code, so the same functions stay usable from tests and custom tools.
6
+ */
7
+ export declare class SandboxError extends Error {
8
+ constructor(message: string, options?: {
9
+ cause?: unknown;
10
+ });
11
+ }
12
+ /**
13
+ * The sandbox cannot start because the developer machine or configuration is
14
+ * incomplete (missing developer id, Pulumi CLI not installed, invalid
15
+ * option values). Carries remediation lines that the dispatcher prints under
16
+ * the error message.
17
+ */
18
+ export declare class SandboxConfigurationError extends SandboxError {
19
+ /** Remediation steps shown to the developer, one line each. */
20
+ readonly remediation: readonly string[];
21
+ constructor(message: string, remediation?: readonly string[], options?: {
22
+ cause?: unknown;
23
+ });
24
+ }
25
+ /**
26
+ * Another process holds the Pulumi state lock for this stack — usually a
27
+ * previous run that was interrupted. Surfaced as a hint to run the `cancel`
28
+ * action instead of a stack trace, and never swallowed, so a `reset` whose
29
+ * destroy half hits the lock does not silently proceed to create.
30
+ */
31
+ export declare class SandboxLockError extends SandboxError {
32
+ readonly stackName: string;
33
+ constructor(stackName: string);
34
+ }
35
+ /**
36
+ * A value could not be rendered into an environment file — an `undefined` or
37
+ * `null` slipped into the declared values, typically an unresolved Pulumi
38
+ * output or a missing configuration entry. Failing loudly here keeps the
39
+ * poisoned value out of the generated file.
40
+ */
41
+ export declare class EnvironmentFileError extends SandboxError {
42
+ }
43
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,qBAAa,YAAa,SAAQ,KAAK;gBACzB,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE;CAI3D;AAED;;;;;GAKG;AACH,qBAAa,yBAA0B,SAAQ,YAAY;IACzD,+DAA+D;IAC/D,QAAQ,CAAC,WAAW,EAAE,SAAS,MAAM,EAAE,CAAC;gBAE5B,OAAO,EAAE,MAAM,EAAE,WAAW,GAAE,SAAS,MAAM,EAAO,EAAE,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE;CAIhG;AAED;;;;;GAKG;AACH,qBAAa,gBAAiB,SAAQ,YAAY;IAChD,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;gBAEf,SAAS,EAAE,MAAM;CAI9B;AAED;;;;;GAKG;AACH,qBAAa,oBAAqB,SAAQ,YAAY;CAAG"}
package/dist/errors.js ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Base class for every error raised by the sandbox harness. Library code
3
+ * throws typed errors and never calls `process.exit` itself; the command
4
+ * line dispatcher in `sandbox()` converts them into friendly output and an
5
+ * exit code, so the same functions stay usable from tests and custom tools.
6
+ */
7
+ export class SandboxError extends Error {
8
+ constructor(message, options) {
9
+ super(message, options);
10
+ this.name = new.target.name;
11
+ }
12
+ }
13
+ /**
14
+ * The sandbox cannot start because the developer machine or configuration is
15
+ * incomplete (missing developer id, Pulumi CLI not installed, invalid
16
+ * option values). Carries remediation lines that the dispatcher prints under
17
+ * the error message.
18
+ */
19
+ export class SandboxConfigurationError extends SandboxError {
20
+ /** Remediation steps shown to the developer, one line each. */
21
+ remediation;
22
+ constructor(message, remediation = [], options) {
23
+ super(message, options);
24
+ this.remediation = remediation;
25
+ }
26
+ }
27
+ /**
28
+ * Another process holds the Pulumi state lock for this stack — usually a
29
+ * previous run that was interrupted. Surfaced as a hint to run the `cancel`
30
+ * action instead of a stack trace, and never swallowed, so a `reset` whose
31
+ * destroy half hits the lock does not silently proceed to create.
32
+ */
33
+ export class SandboxLockError extends SandboxError {
34
+ stackName;
35
+ constructor(stackName) {
36
+ super(`The stack "${stackName}" is locked by another update.`);
37
+ this.stackName = stackName;
38
+ }
39
+ }
40
+ /**
41
+ * A value could not be rendered into an environment file — an `undefined` or
42
+ * `null` slipped into the declared values, typically an unresolved Pulumi
43
+ * output or a missing configuration entry. Failing loudly here keeps the
44
+ * poisoned value out of the generated file.
45
+ */
46
+ export class EnvironmentFileError extends SandboxError {
47
+ }
package/dist/git.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Finds the root of the git repository containing `startDirectory` (default:
3
+ * the current working directory) by walking up until a `.git` entry appears.
4
+ * Returns `undefined` outside a repository.
5
+ *
6
+ * Both a `.git` directory (regular checkout) and a `.git` file (worktrees
7
+ * and submodules store a pointer file instead) count, so sandboxes work from
8
+ * any kind of checkout.
9
+ */
10
+ export declare function findGitRoot(startDirectory?: string): string | undefined;
11
+ //# sourceMappingURL=git.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"git.d.ts","sourceRoot":"","sources":["../src/git.ts"],"names":[],"mappings":"AAGA;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,cAAc,GAAE,MAAsB,GAAG,MAAM,GAAG,SAAS,CAYtF"}
package/dist/git.js ADDED
@@ -0,0 +1,24 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs";
3
+ /**
4
+ * Finds the root of the git repository containing `startDirectory` (default:
5
+ * the current working directory) by walking up until a `.git` entry appears.
6
+ * Returns `undefined` outside a repository.
7
+ *
8
+ * Both a `.git` directory (regular checkout) and a `.git` file (worktrees
9
+ * and submodules store a pointer file instead) count, so sandboxes work from
10
+ * any kind of checkout.
11
+ */
12
+ export function findGitRoot(startDirectory = process.cwd()) {
13
+ let current = path.resolve(startDirectory);
14
+ while (true) {
15
+ if (fs.existsSync(path.join(current, ".git"))) {
16
+ return current;
17
+ }
18
+ const parent = path.dirname(current);
19
+ if (parent === current) {
20
+ return undefined;
21
+ }
22
+ current = parent;
23
+ }
24
+ }
@@ -0,0 +1,55 @@
1
+ /** Environment variable that carries the developer identity. */
2
+ export declare const DEV_ID_VARIABLE = "SANDBOX_DEV_ID";
3
+ /**
4
+ * Default developer identity when none is configured. A single-developer
5
+ * machine works out of the box; teams opt into explicit ids with
6
+ * `require: true`.
7
+ */
8
+ export declare const DEFAULT_DEV_ID = "local";
9
+ export interface DevIdOptions {
10
+ /** Explicit developer id; takes precedence over every other source. */
11
+ devId?: string | undefined;
12
+ /**
13
+ * Optional `KEY=value` file consulted when the environment variable is not
14
+ * set — typically a git-ignored `.env` next to the infrastructure entry
15
+ * point, so each developer configures their identity once per checkout.
16
+ */
17
+ envFile?: string | undefined;
18
+ /**
19
+ * Fail instead of falling back to {@link DEFAULT_DEV_ID}. Teams sharing a
20
+ * remote backend set this so two developers can never collide on the
21
+ * default stack.
22
+ */
23
+ require?: boolean | undefined;
24
+ /** Environment to read from; defaults to `process.env`. */
25
+ environment?: Record<string, string | undefined> | undefined;
26
+ }
27
+ /**
28
+ * Resolves the developer identity that isolates stacks, containers, and
29
+ * volumes per developer. Resolution order: the explicit `devId` option, the
30
+ * `SANDBOX_DEV_ID` environment variable, the optional `envFile`, then
31
+ * {@link DEFAULT_DEV_ID}.
32
+ *
33
+ * The id becomes part of stack names, container names, and volume names, so
34
+ * it is restricted to lowercase letters, digits, and dashes.
35
+ */
36
+ export declare function resolveDevId(options?: DevIdOptions): string;
37
+ /**
38
+ * Parses `KEY=value` content in the dotenv style: blank lines and lines
39
+ * starting with `#` are skipped, the first `=` separates key from value, and
40
+ * both sides are trimmed. No quoting or interpolation — sandbox
41
+ * configuration files stay trivially predictable.
42
+ */
43
+ export declare function parseEnvFile(content: string): Record<string, string>;
44
+ /**
45
+ * Reads and parses a `KEY=value` file, returning `undefined` when the file
46
+ * does not exist so callers can distinguish "not configured" from "empty".
47
+ */
48
+ export declare function readEnvFile(filePath: string): Record<string, string> | undefined;
49
+ /**
50
+ * Interprets a configuration value as a boolean flag. Accepts `true`,
51
+ * `false`, `1`, `0`, `yes`, `no`, `on`, and `off` case-insensitively;
52
+ * anything else — including a missing value — yields `defaultValue`.
53
+ */
54
+ export declare function booleanFlag(value: string | undefined, defaultValue: boolean): boolean;
55
+ //# sourceMappingURL=identity.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"identity.d.ts","sourceRoot":"","sources":["../src/identity.ts"],"names":[],"mappings":"AAGA,gEAAgE;AAChE,eAAO,MAAM,eAAe,mBAAmB,CAAC;AAEhD;;;;GAIG;AACH,eAAO,MAAM,cAAc,UAAU,CAAC;AAItC,MAAM,WAAW,YAAY;IAC3B,uEAAuE;IACvE,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAE3B;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAE7B;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAE9B,2DAA2D;IAC3D,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,GAAG,SAAS,CAAC;CAC9D;AAED;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAAC,OAAO,GAAE,YAAiB,GAAG,MAAM,CA8B/D;AAYD;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAapE;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,CAKhF;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,EAAE,YAAY,EAAE,OAAO,GAAG,OAAO,CASrF"}
@@ -0,0 +1,94 @@
1
+ import fs from "node:fs";
2
+ import { SandboxConfigurationError } from "./errors.js";
3
+ /** Environment variable that carries the developer identity. */
4
+ export const DEV_ID_VARIABLE = "SANDBOX_DEV_ID";
5
+ /**
6
+ * Default developer identity when none is configured. A single-developer
7
+ * machine works out of the box; teams opt into explicit ids with
8
+ * `require: true`.
9
+ */
10
+ export const DEFAULT_DEV_ID = "local";
11
+ const DEV_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
12
+ /**
13
+ * Resolves the developer identity that isolates stacks, containers, and
14
+ * volumes per developer. Resolution order: the explicit `devId` option, the
15
+ * `SANDBOX_DEV_ID` environment variable, the optional `envFile`, then
16
+ * {@link DEFAULT_DEV_ID}.
17
+ *
18
+ * The id becomes part of stack names, container names, and volume names, so
19
+ * it is restricted to lowercase letters, digits, and dashes.
20
+ */
21
+ export function resolveDevId(options = {}) {
22
+ const environment = options.environment ?? process.env;
23
+ if (options.devId !== undefined) {
24
+ return validateDevId(options.devId.trim());
25
+ }
26
+ const fromEnvironment = environment[DEV_ID_VARIABLE]?.trim();
27
+ if (fromEnvironment) {
28
+ return validateDevId(fromEnvironment);
29
+ }
30
+ if (options.envFile !== undefined) {
31
+ const fromFile = readEnvFile(options.envFile)?.[DEV_ID_VARIABLE]?.trim();
32
+ if (fromFile) {
33
+ return validateDevId(fromFile);
34
+ }
35
+ }
36
+ if (options.require) {
37
+ throw new SandboxConfigurationError("No developer id is configured, and this sandbox requires one.", [
38
+ `Set the ${DEV_ID_VARIABLE} environment variable to a short personal id (for example: jdoe),`,
39
+ ...(options.envFile !== undefined ? [`or add "${DEV_ID_VARIABLE}=jdoe" to ${options.envFile}.`] : []),
40
+ ]);
41
+ }
42
+ return DEFAULT_DEV_ID;
43
+ }
44
+ function validateDevId(devId) {
45
+ if (!DEV_ID_PATTERN.test(devId)) {
46
+ throw new SandboxConfigurationError(`The developer id "${devId}" is not usable in stack and container names.`, ["Use lowercase letters, digits, and dashes, starting with a letter or digit (for example: jdoe)."]);
47
+ }
48
+ return devId;
49
+ }
50
+ /**
51
+ * Parses `KEY=value` content in the dotenv style: blank lines and lines
52
+ * starting with `#` are skipped, the first `=` separates key from value, and
53
+ * both sides are trimmed. No quoting or interpolation — sandbox
54
+ * configuration files stay trivially predictable.
55
+ */
56
+ export function parseEnvFile(content) {
57
+ const values = {};
58
+ for (const line of content.split("\n")) {
59
+ const trimmed = line.trim();
60
+ if (trimmed === "" || trimmed.startsWith("#")) {
61
+ continue;
62
+ }
63
+ const separator = trimmed.indexOf("=");
64
+ if (separator > 0) {
65
+ values[trimmed.slice(0, separator).trim()] = trimmed.slice(separator + 1).trim();
66
+ }
67
+ }
68
+ return values;
69
+ }
70
+ /**
71
+ * Reads and parses a `KEY=value` file, returning `undefined` when the file
72
+ * does not exist so callers can distinguish "not configured" from "empty".
73
+ */
74
+ export function readEnvFile(filePath) {
75
+ if (!fs.existsSync(filePath)) {
76
+ return undefined;
77
+ }
78
+ return parseEnvFile(fs.readFileSync(filePath, "utf-8"));
79
+ }
80
+ /**
81
+ * Interprets a configuration value as a boolean flag. Accepts `true`,
82
+ * `false`, `1`, `0`, `yes`, `no`, `on`, and `off` case-insensitively;
83
+ * anything else — including a missing value — yields `defaultValue`.
84
+ */
85
+ export function booleanFlag(value, defaultValue) {
86
+ const normalized = value?.trim().toLowerCase();
87
+ if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") {
88
+ return true;
89
+ }
90
+ if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "off") {
91
+ return false;
92
+ }
93
+ return defaultValue;
94
+ }