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.
- package/LICENSE +21 -0
- package/README.md +282 -0
- package/dist/actions.d.ts +7 -0
- package/dist/actions.d.ts.map +1 -0
- package/dist/actions.js +14 -0
- package/dist/backend.d.ts +40 -0
- package/dist/backend.d.ts.map +1 -0
- package/dist/backend.js +40 -0
- package/dist/docker/exec.d.ts +22 -0
- package/dist/docker/exec.d.ts.map +1 -0
- package/dist/docker/exec.js +26 -0
- package/dist/docker/index.d.ts +12 -0
- package/dist/docker/index.d.ts.map +1 -0
- package/dist/docker/index.js +8 -0
- package/dist/docker/mask-volumes.d.ts +74 -0
- package/dist/docker/mask-volumes.d.ts.map +1 -0
- package/dist/docker/mask-volumes.js +97 -0
- package/dist/docker/shell.d.ts +23 -0
- package/dist/docker/shell.d.ts.map +1 -0
- package/dist/docker/shell.js +34 -0
- package/dist/environment-file.d.ts +58 -0
- package/dist/environment-file.d.ts.map +1 -0
- package/dist/environment-file.js +129 -0
- package/dist/errors.d.ts +43 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +47 -0
- package/dist/git.d.ts +11 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +24 -0
- package/dist/identity.d.ts +55 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +94 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/interactive.d.ts +15 -0
- package/dist/interactive.d.ts.map +1 -0
- package/dist/interactive.js +45 -0
- package/dist/lifecycle.d.ts +41 -0
- package/dist/lifecycle.d.ts.map +1 -0
- package/dist/lifecycle.js +110 -0
- package/dist/outputs.d.ts +30 -0
- package/dist/outputs.d.ts.map +1 -0
- package/dist/outputs.js +65 -0
- package/dist/readiness.d.ts +57 -0
- package/dist/readiness.d.ts.map +1 -0
- package/dist/readiness.js +66 -0
- package/dist/sandbox.d.ts +172 -0
- package/dist/sandbox.d.ts.map +1 -0
- package/dist/sandbox.js +260 -0
- package/dist/state-surgery.d.ts +34 -0
- package/dist/state-surgery.d.ts.map +1 -0
- package/dist/state-surgery.js +94 -0
- package/dist/terminal.d.ts +35 -0
- package/dist/terminal.d.ts.map +1 -0
- package/dist/terminal.js +67 -0
- 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
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -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"}
|
package/dist/identity.js
ADDED
|
@@ -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
|
+
}
|