block-in-file 1.0.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/- +3 -0
- package/.beads/README.md +85 -0
- package/.beads/config.yaml +67 -0
- package/.beads/interactions.jsonl +0 -0
- package/.beads/issues.jsonl +23 -0
- package/.beads/metadata.json +4 -0
- package/.git-blame-ignore-revs +2 -0
- package/.gitattributes +3 -0
- package/.prettierrc.json +5 -0
- package/AGENTS.md +40 -0
- package/README.md +122 -0
- package/block-in-file.ts +150 -0
- package/content +10 -0
- package/deno.json +14 -0
- package/deno.lock +1084 -0
- package/doc/PLAN-envsubst.md +200 -0
- package/doc/PLAN-restructure.md +114 -0
- package/package.json +44 -0
- package/src/attributes.ts +161 -0
- package/src/backup.ts +180 -0
- package/src/block-parser.ts +170 -0
- package/src/block-remover.ts +128 -0
- package/src/conflict-detection.ts +179 -0
- package/src/defaults.ts +23 -0
- package/src/envsubst.ts +59 -0
- package/src/file-processor.ts +378 -0
- package/src/index.ts +5 -0
- package/src/input.ts +69 -0
- package/src/mode-handler.ts +39 -0
- package/src/output.ts +107 -0
- package/src/plugins/.beads/.local_version +1 -0
- package/src/plugins/.beads/issues.jsonl +21 -0
- package/src/plugins/.beads/metadata.json +4 -0
- package/src/plugins/config.ts +282 -0
- package/src/plugins/diff.ts +109 -0
- package/src/plugins/io.ts +72 -0
- package/src/plugins/logger.ts +41 -0
- package/src/tags/tag-merger.ts +31 -0
- package/src/tags/tag-mode.ts +1 -0
- package/src/tags/tag.ts +36 -0
- package/src/tags/tags.ts +4 -0
- package/src/tags/types.ts +4 -0
- package/src/timestamp.ts +39 -0
- package/src/types.ts +32 -0
- package/src/validation.ts +11 -0
- package/test/additive-cli.test.ts +109 -0
- package/test/additive.test.ts +233 -0
- package/test/attributes-integration.test.ts +161 -0
- package/test/attributes.test.ts +100 -0
- package/test/backup.test.ts +386 -0
- package/test/block-in-file.test.ts +235 -0
- package/test/block-parser.test.ts +221 -0
- package/test/block-remover.test.ts +209 -0
- package/test/cli.test.ts +254 -0
- package/test/defaults.test.ts +38 -0
- package/test/envsubst-edge-cases.test.ts +116 -0
- package/test/envsubst-integration.test.ts +78 -0
- package/test/envsubst.test.ts +184 -0
- package/test/input.test.ts +86 -0
- package/test/mode.test.ts +193 -0
- package/test/output.test.ts +44 -0
- package/test/tag-merger.test.ts +176 -0
- package/test/tags.test.ts +116 -0
- package/test/timestamp-integration.test.ts +209 -0
- package/test/timestamp.test.ts +76 -0
- package/tsconfig.json +16 -0
- package/vitest.config.ts +8 -0
package/test/cli.test.ts
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import * as fs from "node:fs/promises";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import { execSync, spawn } from "node:child_process";
|
|
6
|
+
|
|
7
|
+
describe("CLI", () => {
|
|
8
|
+
let tempDir: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "blockinfile-cli-test-"));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
function runCli(args: string, input?: string): string {
|
|
19
|
+
const cwd = path.resolve(import.meta.dirname!, "..");
|
|
20
|
+
const cmd = `npx tsx block-in-file.ts ${args}`;
|
|
21
|
+
return execSync(cmd, {
|
|
22
|
+
cwd,
|
|
23
|
+
input,
|
|
24
|
+
encoding: "utf-8",
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function runCliWithStderr(args: string, input?: string): Promise<string> {
|
|
29
|
+
const cwd = path.resolve(import.meta.dirname!, "..");
|
|
30
|
+
const [command, ...argsArray] = `npx tsx block-in-file.ts ${args}`.split(" ");
|
|
31
|
+
const child = spawn(command, argsArray, {
|
|
32
|
+
cwd,
|
|
33
|
+
shell: true,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const stdout: string[] = [];
|
|
37
|
+
const stderr: string[] = [];
|
|
38
|
+
|
|
39
|
+
if (child.stdout) {
|
|
40
|
+
child.stdout.on("data", (data) => {
|
|
41
|
+
stdout.push(data.toString());
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
if (child.stderr) {
|
|
45
|
+
child.stderr.on("data", (data) => {
|
|
46
|
+
stderr.push(data.toString());
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (input) {
|
|
51
|
+
child.stdin?.write(input);
|
|
52
|
+
child.stdin?.end();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
child.on("close", (code) => {
|
|
57
|
+
if (code === 0) {
|
|
58
|
+
resolve([...stdout, ...stderr].join(""));
|
|
59
|
+
} else {
|
|
60
|
+
reject(new Error(`Command failed with code ${code}`));
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
child.on("error", reject);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe("help and version", () => {
|
|
68
|
+
it("shows help with --help", () => {
|
|
69
|
+
const output = runCli("--help");
|
|
70
|
+
expect(output).toContain("block-in-file");
|
|
71
|
+
expect(output).toContain("Name for block");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("shows version with --version", () => {
|
|
75
|
+
const output = runCli("--version");
|
|
76
|
+
expect(output).toContain("1.0.0");
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("basic file operations", () => {
|
|
81
|
+
it("inserts block from stdin", async () => {
|
|
82
|
+
const targetFile = path.join(tempDir, "target.txt");
|
|
83
|
+
await fs.writeFile(targetFile, "line1\nline2\n");
|
|
84
|
+
|
|
85
|
+
runCli(`-i - ${targetFile}`, "INSERTED");
|
|
86
|
+
|
|
87
|
+
const result = await fs.readFile(targetFile, "utf-8");
|
|
88
|
+
expect(result).toContain("# blockinfile start");
|
|
89
|
+
expect(result).toContain("INSERTED");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("replaces existing block", async () => {
|
|
93
|
+
const targetFile = path.join(tempDir, "target.txt");
|
|
94
|
+
await fs.writeFile(targetFile, "line1\n# blockinfile start\nOLD\n# blockinfile end\nline2\n");
|
|
95
|
+
|
|
96
|
+
runCli(`-i - ${targetFile}`, "NEW");
|
|
97
|
+
|
|
98
|
+
const result = await fs.readFile(targetFile, "utf-8");
|
|
99
|
+
expect(result).not.toContain("OLD");
|
|
100
|
+
expect(result).toContain("NEW");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("creates named block", async () => {
|
|
104
|
+
const targetFile = path.join(tempDir, "target.txt");
|
|
105
|
+
const originalContent = "line1\nline2\n";
|
|
106
|
+
await fs.writeFile(targetFile, originalContent);
|
|
107
|
+
|
|
108
|
+
const output = runCli(`-n myblock -i - ${targetFile} -o -`, "CONTENT");
|
|
109
|
+
|
|
110
|
+
const result = await fs.readFile(targetFile, "utf-8");
|
|
111
|
+
expect(result).toBe(originalContent);
|
|
112
|
+
expect(output).toContain("# myblock start");
|
|
113
|
+
expect(output).toContain("CONTENT");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("outputs to stdout", async () => {
|
|
117
|
+
const targetFile = path.join(tempDir, "target.txt");
|
|
118
|
+
const originalContent = "line1\nline2\n";
|
|
119
|
+
await fs.writeFile(targetFile, originalContent);
|
|
120
|
+
|
|
121
|
+
const output = runCli(`${targetFile} -o -`, "CONTENT");
|
|
122
|
+
|
|
123
|
+
const result = await fs.readFile(targetFile, "utf-8");
|
|
124
|
+
expect(result).toBe(originalContent);
|
|
125
|
+
expect(output).toContain("# blockinfile start");
|
|
126
|
+
expect(output).toContain("CONTENT");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("creates file with create option", async () => {
|
|
130
|
+
const targetFile = path.join(tempDir, "newfile.txt");
|
|
131
|
+
|
|
132
|
+
runCli(`--create file -i - ${targetFile}`, "CONTENT");
|
|
133
|
+
|
|
134
|
+
const result = await fs.readFile(targetFile, "utf-8");
|
|
135
|
+
expect(result).toContain("# blockinfile start");
|
|
136
|
+
expect(result).toContain("CONTENT");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("inserts before matching line", async () => {
|
|
140
|
+
const targetFile = path.join(tempDir, "target.txt");
|
|
141
|
+
await fs.writeFile(targetFile, "line1\nline2\nline3\n");
|
|
142
|
+
|
|
143
|
+
runCli(`--before '^line2' -i - ${targetFile}`, "INSERTED");
|
|
144
|
+
|
|
145
|
+
const result = await fs.readFile(targetFile, "utf-8");
|
|
146
|
+
const lines = result.split("\n");
|
|
147
|
+
expect(lines).toContain("INSERTED");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("inserts after matching line", async () => {
|
|
151
|
+
const targetFile = path.join(tempDir, "target.txt");
|
|
152
|
+
await fs.writeFile(targetFile, "line1\nline2\nline3\n");
|
|
153
|
+
|
|
154
|
+
runCli(`--after '^line2' -i - ${targetFile}`, "INSERTED");
|
|
155
|
+
|
|
156
|
+
const result = await fs.readFile(targetFile, "utf-8");
|
|
157
|
+
const lines = result.split("\n");
|
|
158
|
+
expect(lines).toContain("INSERTED");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("shows diff with --diff", async () => {
|
|
162
|
+
const targetFile = path.join(tempDir, "target.txt");
|
|
163
|
+
await fs.writeFile(targetFile, "line1\nline2\n");
|
|
164
|
+
|
|
165
|
+
const output = await runCliWithStderr(`${targetFile} --diff -`, "NEW BLOCK");
|
|
166
|
+
|
|
167
|
+
expect(output).toContain("---");
|
|
168
|
+
expect(output).toContain("+++");
|
|
169
|
+
expect(output).toContain("+");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("uses dos line endings with --dos", async () => {
|
|
173
|
+
const targetFile = path.join(tempDir, "target.txt");
|
|
174
|
+
await fs.writeFile(targetFile, "line1\nline2\n");
|
|
175
|
+
|
|
176
|
+
runCli(`-i - ${targetFile}`, "CONTENT");
|
|
177
|
+
|
|
178
|
+
const result = await fs.readFile(targetFile, "utf-8");
|
|
179
|
+
expect(result).not.toContain("\r\n");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("uses dos line endings with --dos", async () => {
|
|
183
|
+
const targetFile = path.join(tempDir, "target.txt");
|
|
184
|
+
await fs.writeFile(targetFile, "line1\nline2\n");
|
|
185
|
+
|
|
186
|
+
runCli(`--dos -i - ${targetFile}`, "CONTENT");
|
|
187
|
+
|
|
188
|
+
const result = await fs.readFile(targetFile, "utf-8");
|
|
189
|
+
expect(result).toContain("\r\n");
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("custom markers", () => {
|
|
194
|
+
it("uses custom comment string", async () => {
|
|
195
|
+
const targetFile = path.join(tempDir, "target.txt");
|
|
196
|
+
await fs.writeFile(targetFile, "line1\nline2\n");
|
|
197
|
+
|
|
198
|
+
runCli(`-c '//' -i - ${targetFile}`, "CONTENT");
|
|
199
|
+
|
|
200
|
+
const result = await fs.readFile(targetFile, "utf-8");
|
|
201
|
+
expect(result).toContain("// blockinfile start");
|
|
202
|
+
expect(result).toContain("CONTENT");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("uses custom marker start", async () => {
|
|
206
|
+
const targetFile = path.join(tempDir, "target.txt");
|
|
207
|
+
await fs.writeFile(targetFile, "line1\nline2\n");
|
|
208
|
+
|
|
209
|
+
runCli(`--marker-start BEGIN -i - ${targetFile}`, "CONTENT");
|
|
210
|
+
|
|
211
|
+
const result = await fs.readFile(targetFile, "utf-8");
|
|
212
|
+
expect(result).toContain("# blockinfile BEGIN");
|
|
213
|
+
expect(result).toContain("CONTENT");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("uses custom marker end", async () => {
|
|
217
|
+
const targetFile = path.join(tempDir, "target.txt");
|
|
218
|
+
await fs.writeFile(targetFile, "line1\nline2\n");
|
|
219
|
+
|
|
220
|
+
runCli(`--marker-end FINISH -i - ${targetFile}`, "CONTENT");
|
|
221
|
+
|
|
222
|
+
const result = await fs.readFile(targetFile, "utf-8");
|
|
223
|
+
expect(result).toContain("# blockinfile FINISH");
|
|
224
|
+
expect(result).toContain("CONTENT");
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe("multiple files", () => {
|
|
229
|
+
it("processes multiple files", async () => {
|
|
230
|
+
const file1 = path.join(tempDir, "file1.txt");
|
|
231
|
+
const file2 = path.join(tempDir, "file2.txt");
|
|
232
|
+
await fs.writeFile(file1, "line1\n");
|
|
233
|
+
await fs.writeFile(file2, "line2\n");
|
|
234
|
+
|
|
235
|
+
runCli(`${file1} ${file2} -i -`);
|
|
236
|
+
|
|
237
|
+
const result1 = await fs.readFile(file1, "utf-8");
|
|
238
|
+
const result2 = await fs.readFile(file2, "utf-8");
|
|
239
|
+
expect(result1).toContain("# blockinfile start");
|
|
240
|
+
expect(result2).toContain("# blockinfile start");
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe("debug mode", () => {
|
|
245
|
+
it("outputs debug information with --debug", async () => {
|
|
246
|
+
const targetFile = path.join(tempDir, "target.txt");
|
|
247
|
+
await fs.writeFile(targetFile, "line1\nline2\n");
|
|
248
|
+
|
|
249
|
+
const output = runCli(`--debug -i - ${targetFile}`, "CONTENT");
|
|
250
|
+
|
|
251
|
+
expect(output).toContain("[DEBUG]");
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { defaultOptions, getDefaultOptions } from "../src/defaults.ts";
|
|
3
|
+
|
|
4
|
+
describe("defaults", () => {
|
|
5
|
+
describe("defaultOptions", () => {
|
|
6
|
+
it("has expected default values", () => {
|
|
7
|
+
expect(defaultOptions.comment).toBe("#");
|
|
8
|
+
expect(defaultOptions.markerStart).toBe("start");
|
|
9
|
+
expect(defaultOptions.markerEnd).toBe("end");
|
|
10
|
+
expect(defaultOptions.name).toBe("blockinfile");
|
|
11
|
+
expect(defaultOptions.create).toBe(false);
|
|
12
|
+
expect(defaultOptions.dos).toBe(false);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("is frozen and immutable", () => {
|
|
16
|
+
expect(Object.isFrozen(defaultOptions)).toBe(true);
|
|
17
|
+
expect(() => {
|
|
18
|
+
// @ts-expect-error testing immutability
|
|
19
|
+
defaultOptions.comment = "//";
|
|
20
|
+
}).toThrow();
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("getDefaultOptions", () => {
|
|
25
|
+
it("returns a copy of default options", () => {
|
|
26
|
+
const opts = getDefaultOptions();
|
|
27
|
+
expect(opts).toEqual(defaultOptions);
|
|
28
|
+
expect(opts).not.toBe(defaultOptions);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("returns independent copies", () => {
|
|
32
|
+
const opts1 = getDefaultOptions();
|
|
33
|
+
const opts2 = getDefaultOptions();
|
|
34
|
+
opts1.comment = "//";
|
|
35
|
+
expect(opts2.comment).toBe("#");
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { substitute } from "../src/envsubst.ts";
|
|
3
|
+
|
|
4
|
+
describe("envsubst edge cases", () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
delete process.env.TEST_VAR;
|
|
7
|
+
delete process.env.NESTED;
|
|
8
|
+
delete process.env.VAR;
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe("empty variable names", () => {
|
|
12
|
+
it("should leave ${} unchanged", () => {
|
|
13
|
+
const result = substitute("value: ${}", { mode: "recursive" });
|
|
14
|
+
expect(result).toBe("value: ${}");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("should leave $ unchanged (no variable name)", () => {
|
|
18
|
+
const result = substitute("value: $", { mode: "recursive" });
|
|
19
|
+
expect(result).toBe("value: $");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("should leave ${} undefined - it doesn't match our pattern", () => {
|
|
23
|
+
process.env.EMPTY = "";
|
|
24
|
+
const result = substitute("value: ${EMPTY}", { mode: "recursive" });
|
|
25
|
+
expect(result).toBe("value: ");
|
|
26
|
+
delete process.env.EMPTY;
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("nested braces ${VAR${NESTED}}", () => {
|
|
31
|
+
it("should expand fully in recursive mode", () => {
|
|
32
|
+
process.env.NESTED = "inner";
|
|
33
|
+
process.env.VARinner = "final";
|
|
34
|
+
const result = substitute("${VAR${NESTED}}", { mode: "recursive" });
|
|
35
|
+
// Recursive: expands ${NESTED} to "inner", then ${VARinner} to "final"
|
|
36
|
+
expect(result).toBe("final");
|
|
37
|
+
delete process.env.NESTED;
|
|
38
|
+
delete process.env.VARinner;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("should expand fully in recursive mode with undefined outer var", () => {
|
|
42
|
+
delete process.env.NESTED;
|
|
43
|
+
process.env.VAR = "value";
|
|
44
|
+
const result = substitute("${VAR${NESTED}}", { mode: "recursive" });
|
|
45
|
+
// ${NESTED} becomes "", so ${VAR${NESTED}} becomes ${VAR} which becomes "value"
|
|
46
|
+
expect(result).toBe("value");
|
|
47
|
+
delete process.env.VAR;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("should expand inner only in non-recursive mode (single pass)", () => {
|
|
51
|
+
process.env.NESTED = "inner";
|
|
52
|
+
process.env.VARinner = "final";
|
|
53
|
+
const result = substitute("${VAR${NESTED}}", { mode: "non-recursive" });
|
|
54
|
+
// Non-recursive: expands ${NESTED} to "inner", but not ${VARinner}
|
|
55
|
+
expect(result).toBe("${VARinner}");
|
|
56
|
+
delete process.env.NESTED;
|
|
57
|
+
delete process.env.VARinner;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("should expand inner to empty in non-recursive mode with undefined", () => {
|
|
61
|
+
delete process.env.NESTED;
|
|
62
|
+
const result = substitute("${VAR${NESTED}}", { mode: "non-recursive" });
|
|
63
|
+
// ${NESTED} becomes "", so result is ${VAR}
|
|
64
|
+
expect(result).toBe("${VAR}");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("escaping with backslash", () => {
|
|
69
|
+
it("should not support escaping (matches envsubst behavior)", () => {
|
|
70
|
+
process.env.VAR = "value";
|
|
71
|
+
const result = substitute("\\${VAR}", { mode: "recursive" });
|
|
72
|
+
// envsubst treats backslash as literal, still substitutes
|
|
73
|
+
expect(result).toBe("\\value");
|
|
74
|
+
delete process.env.VAR;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("should handle \\$VAR similarly", () => {
|
|
78
|
+
process.env.VAR = "value";
|
|
79
|
+
const result = substitute("\\$VAR", { mode: "recursive" });
|
|
80
|
+
expect(result).toBe("\\value");
|
|
81
|
+
delete process.env.VAR;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("should handle multiple backslashes", () => {
|
|
85
|
+
process.env.VAR = "value";
|
|
86
|
+
const result = substitute("\\\\${VAR}", { mode: "recursive" });
|
|
87
|
+
expect(result).toBe("\\\\value");
|
|
88
|
+
delete process.env.VAR;
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("special characters", () => {
|
|
93
|
+
it("should not substitute variables with invalid characters", () => {
|
|
94
|
+
process.env["VAR-INVALID"] = "value";
|
|
95
|
+
const result = substitute("${VAR-INVALID}", { mode: "recursive" });
|
|
96
|
+
// Hyphen not in [a-zA-Z0-9_], so won't match
|
|
97
|
+
expect(result).toBe("${VAR-INVALID}");
|
|
98
|
+
delete process.env["VAR-INVALID"];
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("should not substitute variables starting with numbers", () => {
|
|
102
|
+
process.env["1VAR"] = "value";
|
|
103
|
+
const result = substitute("${1VAR}", { mode: "recursive" });
|
|
104
|
+
// Doesn't start with [a-zA-Z_], so won't match
|
|
105
|
+
expect(result).toBe("${1VAR}");
|
|
106
|
+
delete process.env["1VAR"];
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("should substitute variables with underscores", () => {
|
|
110
|
+
process.env.MY_LONG_VAR_NAME_123 = "value";
|
|
111
|
+
const result = substitute("${MY_LONG_VAR_NAME_123}", { mode: "recursive" });
|
|
112
|
+
expect(result).toBe("value");
|
|
113
|
+
delete process.env.MY_LONG_VAR_NAME_123;
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { exec } from "node:child_process";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { unlink, writeFile, readFile, mkdir } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
const execAsync = promisify(exec);
|
|
8
|
+
|
|
9
|
+
describe("envsubst integration", () => {
|
|
10
|
+
const testDir = join(process.cwd(), ".test-envsubst");
|
|
11
|
+
const testFile = join(testDir, "test.txt");
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
try {
|
|
15
|
+
await mkdir(testDir, { recursive: true });
|
|
16
|
+
} catch {}
|
|
17
|
+
await writeFile(testFile, "original content\n", "utf8");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
try {
|
|
22
|
+
await unlink(testFile);
|
|
23
|
+
} catch {}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("should substitute environment variables with ${VAR} syntax", async () => {
|
|
27
|
+
process.env.TEST_VAR = "hello world";
|
|
28
|
+
const input = "content: ${TEST_VAR}";
|
|
29
|
+
|
|
30
|
+
await execAsync(`echo '${input}' | npx tsx block-in-file.ts --envsubst=recursive ${testFile}`);
|
|
31
|
+
|
|
32
|
+
const content = await readFile(testFile, "utf8");
|
|
33
|
+
expect(content).toContain("content: hello world");
|
|
34
|
+
delete process.env.TEST_VAR;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("should substitute nested variables in recursive mode", async () => {
|
|
38
|
+
process.env.VAR1 = "value1";
|
|
39
|
+
process.env.VAR2 = "prefix ${VAR1}";
|
|
40
|
+
const input = "result: ${VAR2}";
|
|
41
|
+
|
|
42
|
+
await execAsync(`echo '${input}' | npx tsx block-in-file.ts --envsubst=recursive ${testFile}`);
|
|
43
|
+
|
|
44
|
+
const content = await readFile(testFile, "utf8");
|
|
45
|
+
expect(content).toContain("result: prefix value1");
|
|
46
|
+
delete process.env.VAR1;
|
|
47
|
+
delete process.env.VAR2;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("should do single pass in non-recursive mode", async () => {
|
|
51
|
+
process.env.VAR1 = "final";
|
|
52
|
+
process.env.VAR2 = "${VAR1}";
|
|
53
|
+
process.env.VAR3 = "${VAR2}";
|
|
54
|
+
const input = "${VAR3}";
|
|
55
|
+
|
|
56
|
+
await execAsync(
|
|
57
|
+
`echo '${input}' | npx tsx block-in-file.ts --envsubst=non-recursive ${testFile}`,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const content = await readFile(testFile, "utf8");
|
|
61
|
+
// Non-recursive: only ${VAR3} is replaced, result is ${VAR2}
|
|
62
|
+
expect(content).toContain("${VAR2}");
|
|
63
|
+
delete process.env.VAR1;
|
|
64
|
+
delete process.env.VAR2;
|
|
65
|
+
delete process.env.VAR3;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("should not substitute when envsubst is false", async () => {
|
|
69
|
+
process.env.TEST_VAR = "value";
|
|
70
|
+
const input = "content: ${TEST_VAR}";
|
|
71
|
+
|
|
72
|
+
await execAsync(`echo '${input}' | npx tsx block-in-file.ts --envsubst=false ${testFile}`);
|
|
73
|
+
|
|
74
|
+
const content = await readFile(testFile, "utf8");
|
|
75
|
+
expect(content).toContain("content: ${TEST_VAR}");
|
|
76
|
+
delete process.env.TEST_VAR;
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { substitute } from "../src/envsubst.ts";
|
|
3
|
+
|
|
4
|
+
describe("envsubst", () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
delete process.env.TEST_VAR;
|
|
7
|
+
delete process.env.NESTED_VAR;
|
|
8
|
+
delete process.env.PREFIX_VAR;
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe("basic substitution with ${VAR} syntax", () => {
|
|
12
|
+
it.each([
|
|
13
|
+
{ input: "content: ${TEST_VAR}", vars: { TEST_VAR: "hello" }, expected: "content: hello" },
|
|
14
|
+
{ input: "${VAR1} and ${VAR2}", vars: { VAR1: "foo", VAR2: "bar" }, expected: "foo and bar" },
|
|
15
|
+
{ input: "${PREFIX}-middle-${SUFFIX}", vars: { PREFIX: "pre", SUFFIX: "post" }, expected: "pre-middle-post" },
|
|
16
|
+
])("should substitute: $input", ({ input, vars, expected }) => {
|
|
17
|
+
Object.assign(process.env, vars);
|
|
18
|
+
const result = substitute(input, { mode: "non-recursive" });
|
|
19
|
+
expect(result).toBe(expected);
|
|
20
|
+
Object.keys(vars).forEach(key => delete process.env[key]);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("basic substitution with $VAR syntax", () => {
|
|
25
|
+
it.each([
|
|
26
|
+
{ input: "content: $TEST_VAR", vars: { TEST_VAR: "hello" }, expected: "content: hello" },
|
|
27
|
+
{ input: "$VAR1 and $VAR2", vars: { VAR1: "foo", VAR2: "bar" }, expected: "foo and bar" },
|
|
28
|
+
])("should substitute: $input", ({ input, vars, expected }) => {
|
|
29
|
+
Object.assign(process.env, vars);
|
|
30
|
+
const result = substitute(input, { mode: "non-recursive" });
|
|
31
|
+
expect(result).toBe(expected);
|
|
32
|
+
Object.keys(vars).forEach(key => delete process.env[key]);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("priority - ${VAR} over $VAR", () => {
|
|
37
|
+
it("should prefer ${VAR} syntax when both are present", () => {
|
|
38
|
+
process.env.VAR = "value";
|
|
39
|
+
const result = substitute("${VAR} and $VAR", { mode: "non-recursive" });
|
|
40
|
+
expect(result).toBe("value and value");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("should not substitute $VAR if it's part of ${VAR}", () => {
|
|
44
|
+
process.env.VAR = "value";
|
|
45
|
+
process.env.VAR_BRACE = "other";
|
|
46
|
+
const result = substitute("${VAR}", { mode: "non-recursive" });
|
|
47
|
+
expect(result).toBe("value");
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("recursive mode", () => {
|
|
52
|
+
it("should substitute nested variables", () => {
|
|
53
|
+
process.env.VAR1 = "value1";
|
|
54
|
+
process.env.VAR2 = "prefix ${VAR1}";
|
|
55
|
+
const result = substitute("result: ${VAR2}", { mode: "recursive" });
|
|
56
|
+
expect(result).toBe("result: prefix value1");
|
|
57
|
+
delete process.env.VAR1;
|
|
58
|
+
delete process.env.VAR2;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("should handle multiple levels of nesting", () => {
|
|
62
|
+
process.env.VAR1 = "final";
|
|
63
|
+
process.env.VAR2 = "level2 ${VAR1}";
|
|
64
|
+
process.env.VAR3 = "level1 ${VAR2}";
|
|
65
|
+
const result = substitute("start ${VAR3}", { mode: "recursive" });
|
|
66
|
+
expect(result).toBe("start level1 level2 final");
|
|
67
|
+
delete process.env.VAR1;
|
|
68
|
+
delete process.env.VAR2;
|
|
69
|
+
delete process.env.VAR3;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("should stop when substitution is stable", () => {
|
|
73
|
+
process.env.VAR1 = "value";
|
|
74
|
+
process.env.VAR2 = "${VAR1}";
|
|
75
|
+
const result = substitute("${VAR2}", { mode: "recursive" });
|
|
76
|
+
expect(result).toBe("value");
|
|
77
|
+
delete process.env.VAR1;
|
|
78
|
+
delete process.env.VAR2;
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("non-recursive mode", () => {
|
|
83
|
+
it("should do single pass substitution (like envsubst)", () => {
|
|
84
|
+
process.env.VAR1 = "value1";
|
|
85
|
+
process.env.VAR2 = "prefix ${VAR1}";
|
|
86
|
+
const result = substitute("result: ${VAR2}", { mode: "non-recursive" });
|
|
87
|
+
// Only ${VAR2} is replaced with its value, not ${VAR1}
|
|
88
|
+
expect(result).toBe("result: prefix ${VAR1}");
|
|
89
|
+
delete process.env.VAR1;
|
|
90
|
+
delete process.env.VAR2;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("should handle nested variables in one pass only", () => {
|
|
94
|
+
process.env.VAR1 = "final";
|
|
95
|
+
process.env.VAR2 = "${VAR1}";
|
|
96
|
+
process.env.VAR3 = "${VAR2}";
|
|
97
|
+
const result = substitute("${VAR3}", { mode: "non-recursive" });
|
|
98
|
+
// Only ${VAR3} is replaced, result stays as ${VAR2}
|
|
99
|
+
expect(result).toBe("${VAR2}");
|
|
100
|
+
delete process.env.VAR1;
|
|
101
|
+
delete process.env.VAR2;
|
|
102
|
+
delete process.env.VAR3;
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("undefined variables", () => {
|
|
107
|
+
it.each([
|
|
108
|
+
{ input: "content: ${UNDEFINED_VAR}", expected: "content: " },
|
|
109
|
+
{ input: "content: $UNDEFINED_VAR", expected: "content: " },
|
|
110
|
+
])("should replace undefined variables with empty string: $input", ({ input, expected }) => {
|
|
111
|
+
const result = substitute(input, { mode: "non-recursive" });
|
|
112
|
+
expect(result).toBe(expected);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("should handle mix of defined and undefined variables", () => {
|
|
116
|
+
process.env.DEFINED_VAR = "value";
|
|
117
|
+
const result = substitute("${DEFINED_VAR} and ${UNDEFINED_VAR}", { mode: "non-recursive" });
|
|
118
|
+
expect(result).toBe("value and ");
|
|
119
|
+
delete process.env.DEFINED_VAR;
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("should handle variables with empty values", () => {
|
|
123
|
+
process.env.EMPTY_VAR = "";
|
|
124
|
+
const result = substitute("prefix${EMPTY_VAR}suffix", { mode: "non-recursive" });
|
|
125
|
+
expect(result).toBe("prefixsuffix");
|
|
126
|
+
delete process.env.EMPTY_VAR;
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("edge cases", () => {
|
|
131
|
+
it.each([
|
|
132
|
+
{ input: "", expected: "" },
|
|
133
|
+
{ input: "just plain text", expected: "just plain text" },
|
|
134
|
+
])("should handle: $input", ({ input, expected }) => {
|
|
135
|
+
const result = substitute(input, { mode: "non-recursive" });
|
|
136
|
+
expect(result).toBe(expected);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it.each([
|
|
140
|
+
{ input: "${MY_LONG_VAR_NAME}", varName: "MY_LONG_VAR_NAME", value: "value", expected: "value" },
|
|
141
|
+
{ input: "${VAR123}", varName: "VAR123", value: "value", expected: "value" },
|
|
142
|
+
])("should handle variable names with special characters: $input", ({ input, varName, value, expected }) => {
|
|
143
|
+
process.env[varName] = value;
|
|
144
|
+
const result = substitute(input, { mode: "non-recursive" });
|
|
145
|
+
expect(result).toBe(expected);
|
|
146
|
+
delete process.env[varName];
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("should handle variables with empty values", () => {
|
|
150
|
+
process.env.EMPTY_VAR = "";
|
|
151
|
+
const result = substitute("prefix${EMPTY_VAR}suffix", { mode: "non-recursive" });
|
|
152
|
+
expect(result).toBe("prefixsuffix");
|
|
153
|
+
delete process.env.EMPTY_VAR;
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("mode: false", () => {
|
|
158
|
+
it("should not substitute when mode is false", () => {
|
|
159
|
+
process.env.TEST_VAR = "value";
|
|
160
|
+
const result = substitute("content: ${TEST_VAR}", { mode: false });
|
|
161
|
+
expect(result).toBe("content: ${TEST_VAR}");
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("malformed syntax", () => {
|
|
166
|
+
it("should handle malformed ${ at end", () => {
|
|
167
|
+
process.env.TEST_VAR = "value";
|
|
168
|
+
const result = substitute("content: ${", { mode: "non-recursive" });
|
|
169
|
+
expect(result).toBe("content: ${");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("should handle malformed ${VAR without closing brace", () => {
|
|
173
|
+
process.env.TEST_VAR = "value";
|
|
174
|
+
const result = substitute("content: ${TEST_VAR", { mode: "non-recursive" });
|
|
175
|
+
expect(result).toBe("content: ${TEST_VAR");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("should handle $ at end", () => {
|
|
179
|
+
process.env.TEST_VAR = "value";
|
|
180
|
+
const result = substitute("content: $", { mode: "non-recursive" });
|
|
181
|
+
expect(result).toBe("content: $");
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
});
|